2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.lutron.internal.grxprg;
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;
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;
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;
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,
43 * @author Tim Roberts - Initial contribution
46 class PrgProtocolHandler {
47 private Logger logger = LoggerFactory.getLogger(PrgProtocolHandler.class);
50 * The {@link SocketSession} used by this protocol handler
52 private final SocketSession session;
55 * The {@link PrgBridgeHandler} to call back to update status and state
57 private final PrgHandlerCallback phCallback;
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?";
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";
105 * A lookup between a 0-100 percentage and corresponding hex value. Note: this specifically matches the liason
108 private static final Map<Integer, String> INTENSITY_MAP = new HashMap<>();
111 * The reverse lookup for the {{@link #INTENSITY_MAP}
113 private static final Map<String, Integer> REVERSE_INTENSITY_MAP = new HashMap<>();
116 * A lookup between returned shade hex intensity to corresponding shade values
118 private static final Map<String, Integer> SHADE_INTENSITY_MAP = new HashMap<>();
121 * Cache of current zone intensities
123 private final int[] zoneIntensities = new int[8];
126 * Static method to setup the intensity lookup maps
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");
231 for (int key : INTENSITY_MAP.keySet()) {
232 String value = INTENSITY_MAP.get(key);
233 REVERSE_INTENSITY_MAP.put(value, key);
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);
251 * Lookup of valid scene numbers (H is also sometimes returned - no idea what it is however)
253 private static final String VALID_SCENES = "0123456789ABCDEFG";
256 * Constructs the protocol handler from given parameters
258 * @param session a non-null {@link SocketSession} (may be connected or disconnected)
259 * @param config a non-null {@link PrgHandlerCallback}
261 PrgProtocolHandler(SocketSession session, PrgHandlerCallback callback) {
262 if (session == null) {
263 throw new IllegalArgumentException("session cannot be null");
266 if (callback == null) {
267 throw new IllegalArgumentException("callback cannot be null");
270 this.session = session;
271 this.phCallback = callback;
275 * Attempts to log into the interface.
277 * @return a null if logged in successfully. Non-null if an exception occurred.
278 * @throws IOException an IO exception occurred during login
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);
285 String response = callback.getResponse();
286 if ("login".equals(response)) {
287 session.sendCommand(username);
289 return "Protocol violation - wasn't initially a command failure or login prompt: " + response;
292 // We should have received back a connection established response
293 response = callback.getResponse();
295 // Burn the empty response if we got one (
296 if ("".equals(response)) {
297 response = callback.getResponse();
300 if (RSP_CONNECTION_ESTABLISHED.equals(response)) {
304 return "login failed";
309 * Post successful login stuff - mark us online and refresh from the switch
311 * @throws IOException
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);
320 * Refreshes the state of the specified control unit
322 * @param controlUnit the control unit to refresh
324 void refreshState(int controlUnit) {
325 logger.debug("Refreshing control unit ({}) state", controlUnit);
329 refreshSunriseSunset();
330 reportSuperSequenceStatus();
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
336 // Get the current state of the zone/scene lock
337 // sendCommand("spm");
338 // sendCommand("rmu " + controlUnit);
339 // sendCommand("epm");
341 refreshZoneIntensity(controlUnit);
345 * Validate the control unit parameter
347 * @param controlUnit a control unit between 1-8
348 * @throws IllegalArgumentException if controlUnit is < 0 or > 8
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);
357 * Validates the scene and converts it to the corresponding hex value
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
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);
368 return VALID_SCENES.charAt(scene);
374 * @param zone the zone to validate
375 * @throws IllegalArgumentException if zone < 1 or > 8
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);
384 * Validates the fade and converts it to hex
386 * @param fade the fade
387 * @return a valid fade value
388 * @throws IllegalArgumentException if fade < 0 or > 120
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);
395 fade = (fade / 60) + 59;
397 return Integer.toHexString(fade).toUpperCase();
401 * Validates a zone intensity and returns the hex corresponding value (handles shade intensity zones as well)
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
409 private String convertIntensity(int controlUnit, int zone, int intensity) {
410 validateControlUnit(controlUnit);
413 if (intensity < 0 || intensity > 100) {
414 throw new IllegalArgumentException("Invalid intensity (must be between 0 and 100): " + intensity);
417 final boolean isShade = phCallback.isShade(controlUnit, zone);
420 throw new IllegalArgumentException("Invalid SHADE intensity (must be between 0 and 5): " + intensity);
422 return Integer.toString(intensity);
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();
434 * Converts a hex zone intensity back to an integer - handles shade zones as well
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
442 private int convertIntensity(int controlUnit, int zone, String intensity) {
443 validateControlUnit(controlUnit);
446 final boolean isShade = phCallback.isShade(controlUnit, zone);
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);
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);
461 zoneIntensities[zone] = intNbr;
467 * Selects a specific scene on a control unit
469 * @param controlUnit the control unit
470 * @param scene the new scene
471 * @throws IllegalArgumentException if controlUnit or scene are invalid
473 void selectScene(int controlUnit, int scene) {
474 validateControlUnit(controlUnit);
475 sendCommand(CMD_SCENE + convertScene(scene) + controlUnit);
479 * Queries the interface for the current scene status on all control units
481 void refreshScene() {
482 sendCommand(CMD_SCENESTATUS);
486 * Sets the scene locked/unlocked for the specific control unit
488 * @param controlUnit the control unit
489 * @param locked true for locked, false otherwise
490 * @throws IllegalArgumentException if controlUnit is invalid
492 void setSceneLock(int controlUnit, boolean locked) {
493 validateControlUnit(controlUnit);
494 sendCommand(CMD_SCENELOCK + (locked ? "+" : "-") + controlUnit);
498 * Sets the scene sequence on/off for the specific control unit
500 * @param controlUnit the control unit
501 * @param on true for sequencing on, false otherwise
502 * @throws IllegalArgumentException if controlUnit is invalid
504 void setSceneSequence(int controlUnit, boolean on) {
505 validateControlUnit(controlUnit);
506 sendCommand(CMD_SCENESEQ + (on ? "+" : "-") + controlUnit);
510 * Sets the zone locked/unlocked for the specific control unit
512 * @param controlUnit the control unit
513 * @param locked true for locked, false otherwise
514 * @throws IllegalArgumentException if controlUnit is invalid
516 void setZoneLock(int controlUnit, boolean locked) {
517 validateControlUnit(controlUnit);
518 sendCommand(CMD_ZONELOCK + (locked ? "+" : "-") + controlUnit);
522 * Sets the zone to lowering for the specific control unit
524 * @param controlUnit the control unit
525 * @param zone the zone to lower
526 * @throws IllegalArgumentException if controlUnit or zone is invalid
528 void setZoneLower(int controlUnit, int zone) {
529 validateControlUnit(controlUnit);
531 sendCommand(CMD_ZONELOWER + controlUnit + zone);
535 * Stops the zone lowering on all control units
537 void setZoneLowerStop() {
538 sendCommand(CMD_ZONELOWERSTOP);
542 * Sets the zone to raising for the specific control unit
544 * @param controlUnit the control unit
545 * @param zone the zone to raise
546 * @throws IllegalArgumentException if controlUnit or zone is invalid
548 void setZoneRaise(int controlUnit, int zone) {
549 validateControlUnit(controlUnit);
551 sendCommand(CMD_ZONERAISE + controlUnit + zone);
555 * Stops the zone raising on all control units
557 void setZoneRaiseStop() {
558 sendCommand(CMD_ZONERAISESTOP);
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.
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
571 void setZoneIntensity(int controlUnit, int zone, int fade, boolean increase) {
572 if (phCallback.isShade(controlUnit, zone)) {
576 validateControlUnit(controlUnit);
579 int newInt = zoneIntensities[zone] += (increase ? 1 : -1);
587 setZoneIntensity(controlUnit, zone, fade, newInt);
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).
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
600 void setZoneIntensity(int controlUnit, int zone, int fade, int intensity) {
601 validateControlUnit(controlUnit);
604 final String hexFade = convertFade(fade);
605 final String hexIntensity = convertIntensity(controlUnit, zone, intensity);
607 final StringBuilder sb = new StringBuilder(16);
608 for (int z = 1; z <= 8; z++) {
610 sb.append(zone == z ? hexIntensity : "*");
613 sendCommand(CMD_ZONEINTENSITY + " " + controlUnit + " " + hexFade + sb);
617 * Refreshes the current zone intensities for the control unit
619 * @param controlUnit the control unit
620 * @throws IllegalArgumentException if control unit is invalid
622 void refreshZoneIntensity(int controlUnit) {
623 validateControlUnit(controlUnit);
624 sendCommand(CMD_ZONEINTENSITYSTATUS + " " + controlUnit);
628 * Sets the time on the PRG interface
630 * @param calendar a non-null calendar to set the time to
631 * @throws IllegalArgumentException if calendar is null
633 void setTime(Calendar calendar) {
634 if (calendar == null) {
635 throw new IllegalArgumentException("calendar cannot be null");
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));
643 * Refreshes the time from the PRG interface
646 sendCommand(CMD_READTIME);
650 * Selects the specific schedule (0=none, 1=weekday, 2=weekend)
652 * @param schedule the new schedule
653 * @throws IllegalArgumentException if schedule is < 0 or > 32
655 void selectSchedule(int schedule) {
656 if (schedule < 0 || schedule > 2) {
657 throw new IllegalArgumentException("Schedule invalid (must be between 0 and 2): " + schedule);
659 sendCommand(CMD_SELECTSCHEDULE + " " + schedule);
663 * Refreshes the current schedule
665 void refreshSchedule() {
666 sendCommand(CMD_REPORTSCHEDULE);
670 * Refreshs the current sunrise/sunset
672 void refreshSunriseSunset() {
673 sendCommand(CMD_SUNRISESUNSET);
677 * Starts the super sequence
679 void startSuperSequence() {
680 sendCommand(CMD_SUPERSEQUENCESTART);
681 reportSuperSequenceStatus();
685 * Pauses the super sequence
687 void pauseSuperSequence() {
688 sendCommand(CMD_SUPERSEQUENCEPAUSE);
692 * Resumes the super sequence
694 void resumeSuperSequence() {
695 sendCommand(CMD_SUPERSEQUENCERESUME);
699 * Refreshes the status of the super sequence
701 void reportSuperSequenceStatus() {
702 sendCommand(CMD_SUPERSEQUENCESTATUS);
706 * Sends the command and puts the thing into {@link ThingStatus#OFFLINE} if an IOException occurs
708 * @param command a non-null, non-empty command to send
709 * @throws IllegalArgumentException if command is null or empty
711 private void sendCommand(String command) {
712 if (command == null) {
713 throw new IllegalArgumentException("command cannot be null");
715 if (command.trim().length() == 0) {
716 throw new IllegalArgumentException("command cannot be empty");
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);
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)
731 * @param resp the possibly null, possibly empty actual response
733 private void handleCommandFailure(Matcher m, String resp) {
735 throw new IllegalArgumentException("m (matcher) cannot be null");
737 if (m.groupCount() == 2) {
739 final int errorNbr = Integer.parseInt(m.group(1));
740 String errorMsg = "ErrorCode: " + errorNbr;
743 errorMsg = "Control Unit Raise/Lower error";
747 errorMsg = "Invalid scene selected";
751 errorMsg = "Bad command was sent";
755 errorMsg = "Not a timeclock unit (GRX-ATC or GRX-PRG)";
759 errorMsg = "Illegal time was entered";
763 errorMsg = "Invalid schedule";
767 errorMsg = "No Super Sequence has been loaded";
771 errorMsg = "Command was missing Control Units";
775 errorMsg = "Command was missing data";
779 errorMsg = "Error in command argument (improper hex value)";
783 errorMsg = "Invalid Control Unit";
787 errorMsg = "Invalid value, outside range of acceptable values";
791 errorMsg = "Invalid Accessory Control";
795 errorMsg = "Network address illegally formatted; 4 octets required (xxx.xxx.xxx.xxx)";
799 errorMsg = "Time-out error, no response received";
803 errorMsg = "Invalid Telnet login number";
807 errorMsg = "Invalid Telnet login";
811 errorMsg = "Telnet login name exceeds 8 characters";
815 errorMsg = "INvalid number of arguments";
819 errorMsg = "GRX-PRG must be in programming mode for specific commands";
823 logger.error("Error response: {} ({})", errorMsg, errorNbr);
824 } catch (NumberFormatException e) {
825 logger.error("Invalid failure response (can't parse error number): '{}'", resp);
828 logger.error("Invalid failure response: '{}'", resp);
833 * Handles the scene status response
835 * @param m the non-null {@link Matcher} that matched the response
836 * @param resp the possibly null, possibly empty actual response
838 private void handleSceneStatus(Matcher m, String resp) {
840 throw new IllegalArgumentException("m (matcher) cannot be null");
842 if (m.groupCount() >= 2) {
844 final String sceneStatus = m.group(1);
845 for (int i = 1; i <= 8; i++) {
846 char status = sceneStatus.charAt(i - 1);
848 continue; // no control unit
851 int scene = VALID_SCENES.indexOf(status);
853 logger.warn("Unknown scene status returned for zone {}: {}", i, status);
855 phCallback.stateChanged(i, PrgConstants.CHANNEL_SCENE, new DecimalType(scene));
856 refreshZoneIntensity(i); // request to get new zone intensities
859 } catch (NumberFormatException e) {
860 logger.error("Invalid scene status (can't parse scene #): '{}'", resp);
863 logger.error("Invalid scene status response: '{}'", resp);
868 * Handles the report time response
870 * @param m the non-null {@link Matcher} that matched the response
871 * @param resp the possibly null, possibly empty actual response
873 private void handleReportTime(Matcher m, String resp) {
875 throw new IllegalArgumentException("m (matcher) cannot be null");
877 if (m.groupCount() == 7) {
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)));
885 final int yr = Integer.parseInt(m.group(5));
886 c.set(Calendar.YEAR, yr + (yr < 50 ? 1900 : 2000));
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);
894 logger.error("Invalid time response: '{}'", resp);
899 * Handles the report schedule response
901 * @param m the non-null {@link Matcher} that matched the response
902 * @param resp the possibly null, possibly empty actual response
904 private void handleReportSchedule(Matcher m, String resp) {
906 throw new IllegalArgumentException("m (matcher) cannot be null");
908 if (m.groupCount() == 2) {
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);
916 logger.error("Invalid schedule volume response: '{}'", resp);
921 * Handles the sunrise/sunset response
923 * @param m the non-null {@link Matcher} that matched the response
924 * @param resp the possibly null, possibly empty actual response
926 private void handleSunriseSunset(Matcher m, String resp) {
928 throw new IllegalArgumentException("m (matcher) cannot be null");
930 if (m.groupCount() == 5) {
931 if ("255".equals(m.group(1))) {
932 logger.warn("Sunrise/Sunset needs to be enabled via Liason Software");
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())));
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);
951 logger.error("Invalid sunrise/sunset response: '{}'", resp);
956 * Handles the super sequence response
958 * @param m the non-null {@link Matcher} that matched the response
959 * @param resp the possibly null, possibly empty actual response
961 private void handleSuperSequenceStatus(Matcher m, String resp) {
963 throw new IllegalArgumentException("m (matcher) cannot be null");
965 if (m.groupCount() == 5) {
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);
978 logger.error("Invalid format volume response: '{}'", resp);
983 * Handles the zone intensity response
985 * @param m the non-null {@link Matcher} that matched the response
986 * @param resp the possibly null, possibly empty actual response
988 private void handleZoneIntensity(Matcher m, String resp) {
990 throw new IllegalArgumentException("m (matcher) cannot be null");
993 if (m.groupCount() == 10) {
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
1001 final int zid = convertIntensity(controlUnit, z, zi);
1003 phCallback.stateChanged(controlUnit, PrgConstants.CHANNEL_ZONEINTENSITY + z, new PercentType(zid));
1005 } catch (NumberFormatException e) {
1006 logger.error("Invalid volume response (can't parse number): '{}'", resp);
1009 logger.error("Invalid format volume response: '{}'", resp);
1014 * Handles the controller information response (currently not used).
1016 * @param m the non-null {@link Matcher} that matched the response
1017 * @param resp the possibly null, possibly empty actual response
1019 private void handleControlInfo(Matcher m, String resp) {
1021 throw new IllegalArgumentException("m (matcher) cannot be null");
1023 if (m.groupCount() == 9) {
1024 int controlUnit = 0;
1026 controlUnit = Integer.parseInt(m.group(1));
1028 final String q4 = m.group(8);
1029 final String q4bits = new StringBuilder(Integer.toBinaryString(Integer.parseInt(q4, 16))).reverse()
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';
1036 phCallback.stateChanged(controlUnit, PrgConstants.CHANNEL_SCENESEQ, OnOffType.from(seqMode));
1037 phCallback.stateChanged(controlUnit, PrgConstants.CHANNEL_SCENELOCK, OnOffType.from(sceneLock));
1038 phCallback.stateChanged(controlUnit, PrgConstants.CHANNEL_ZONELOCK, OnOffType.from(zoneLock));
1039 } catch (NumberFormatException e) {
1040 logger.error("Invalid controller information response: '{}'", resp);
1043 logger.error("Invalid controller information response: '{}'", resp);
1048 * Handles the interface being reset
1050 * @param m the non-null {@link Matcher} that matched the response
1051 * @param resp the possibly null, possibly empty actual response
1053 private void handleResetting(Matcher m, String resp) {
1054 phCallback.statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.DUTY_CYCLE, "Device resetting");
1058 * Handles the button press response
1060 * @param m the non-null {@link Matcher} that matched the response
1061 * @param resp the possibly null, possibly empty actual response
1063 private void handleButton(Matcher m, String resp) {
1064 phCallback.stateChanged(PrgConstants.CHANNEL_BUTTONPRESS, new StringType(resp));
1068 * Handles an unknown response (simply logs it)
1070 * @param resp the possibly null, possibly empty actual response
1072 private void handleUnknownCommand(String response) {
1073 logger.info("Unhandled response: {}", response);
1077 * This callback is our normal response callback. Should be set into the {@link SocketSession} after the login
1078 * process to handle normal responses.
1080 * @author Tim Roberts
1083 private class NormalResponseCallback implements SocketSessionCallback {
1086 public void responseReceived(String response) {
1087 // logger.debug("Response received: " + response);
1089 if (response == null || response.trim().length() == 0) {
1090 return; // simple blank - do nothing
1093 Matcher m = RSP_OK.matcher(response);
1095 // logger.debug(response);
1096 return; // nothing to do on an OK! response
1099 m = RSP_FAILED.matcher(response);
1101 handleCommandFailure(m, response);
1102 return; // nothing really to do on an error response either
1105 m = RSP_SCENESTATUS.matcher(response);
1107 handleSceneStatus(m, response);
1111 m = RSP_REPORTIME.matcher(response);
1113 handleReportTime(m, response);
1117 m = RSP_REPORTSCHEDULE.matcher(response);
1119 handleReportSchedule(m, response);
1123 m = RSP_SUNRISESUNSET.matcher(response);
1125 handleSunriseSunset(m, response);
1129 m = RSP_SUPERSEQUENCESTATUS.matcher(response);
1131 handleSuperSequenceStatus(m, response);
1135 m = RSP_ZONEINTENSITY.matcher(response);
1137 handleZoneIntensity(m, response);
1141 m = RSP_RMU.matcher(response);
1143 handleControlInfo(m, response);
1147 m = RSP_RESETTING.matcher(response);
1149 handleResetting(m, response);
1153 m = RSP_BUTTON.matcher(response);
1155 handleButton(m, response);
1159 if (RSP_CONNECTION_ESTABLISHED.equals(response)) {
1160 return; // nothing to do on connection established
1163 handleUnknownCommand(response);
1167 public void responseException(Exception exception) {
1168 phCallback.statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1169 "Exception occurred reading from PRG: " + exception);
1174 * Special callback used during the login process to not dispatch the responses to this class but rather give them
1175 * back at each call to {@link NoDispatchingCallback#getResponse()}
1177 * @author Tim Roberts
1180 private class NoDispatchingCallback implements SocketSessionCallback {
1183 * Cache of responses that have occurred
1185 private BlockingQueue<Object> responses = new ArrayBlockingQueue<>(5);
1188 * Will return the next response from {@link #responses}. If the response is an exception, that exception will
1189 * be thrown instead.
1191 * @return a non-null, possibly empty response
1192 * @throws Exception an exception if one occurred during reading
1194 String getResponse() throws Exception {
1195 final Object lastResponse = responses.poll(5, TimeUnit.SECONDS);
1196 if (lastResponse instanceof String str) {
1198 } else if (lastResponse instanceof Exception exception) {
1200 } else if (lastResponse == null) {
1201 throw new Exception("Didn't receive response in time");
1203 return lastResponse.toString();
1208 public void responseReceived(String response) {
1210 responses.put(response);
1211 } catch (InterruptedException e) {
1216 public void responseException(Exception e) {
1219 } catch (InterruptedException e1) {