]> git.basschouten.com Git - openhab-addons.git/blob
5fa7ebd2f7784858cf32b06d89893dc9128d219c
[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.lcn.internal.common;
14
15 import org.eclipse.jdt.annotation.NonNullByDefault;
16 import org.openhab.binding.lcn.internal.LcnBindingConstants;
17 import org.slf4j.Logger;
18 import org.slf4j.LoggerFactory;
19
20 /**
21  * Helpers to generate LCN-PCK commands.
22  * <p>
23  * LCN-PCK is the command-syntax used by LCN-PCHK to send and receive LCN commands.
24  *
25  * @author Tobias Jüttner - Initial Contribution
26  * @author Fabian Wolter - Migration to OH2
27  */
28 @NonNullByDefault
29 public final class PckGenerator {
30     private static final Logger LOGGER = LoggerFactory.getLogger(PckGenerator.class);
31     /** Termination character after a PCK message */
32     public static final String TERMINATION = "\n";
33
34     /**
35      * Generates a keep-alive.
36      * LCN-PCHK will close the connection if it does not receive any commands from
37      * an open {@link org.openhab.binding.lcn.internal.connection.Connection} for a specific period
38      * (10 minutes by default).
39      *
40      * @param counter the current ping's id (optional, but "best practice"). Should start with 1
41      * @return the PCK command as text
42      */
43     public static String ping(int counter) {
44         return String.format("^ping%d", counter);
45     }
46
47     /**
48      * Generates a PCK command that will set the LCN-PCHK connection's operation mode.
49      * This influences how output-port commands and status are interpreted and must be
50      * in sync with the LCN bus.
51      *
52      * @param dimMode see {@link LcnDefs.OutputPortDimMode}
53      * @param statusMode see {@link LcnDefs.OutputPortStatusMode}
54      * @return the PCK command as text
55      */
56     public static String setOperationMode(LcnDefs.OutputPortDimMode dimMode, LcnDefs.OutputPortStatusMode statusMode) {
57         return "!OM" + (dimMode == LcnDefs.OutputPortDimMode.NATIVE200 ? "1" : "0")
58                 + (statusMode == LcnDefs.OutputPortStatusMode.PERCENT ? "P" : "N");
59     }
60
61     /**
62      * Generates a PCK address header.
63      * Used for commands to LCN modules and groups.
64      *
65      * @param addr the target's address (module or group)
66      * @param localSegId the local segment id where the physical bus connection is located
67      * @param wantsAck true to claim an acknowledge / receipt from the target
68      * @return the PCK address header as text
69      */
70     public static String generateAddressHeader(LcnAddr addr, int localSegId, boolean wantsAck) {
71         return String.format(">%s%03d%03d%s", addr.isGroup() ? "G" : "M", addr.getPhysicalSegmentId(localSegId),
72                 addr.getId(), wantsAck ? "!" : ".");
73     }
74
75     /**
76      * Generates a scan-command for LCN segment-couplers.
77      * Used to detect the local segment (where the physical bus connection is located).
78      *
79      * @return the PCK command (without address header) as text
80      */
81     public static String segmentCouplerScan() {
82         return "SK";
83     }
84
85     /**
86      * Generates a firmware/serial-number request.
87      *
88      * @return the PCK command (without address header) as text
89      */
90     public static String requestSn() {
91         return "SN";
92     }
93
94     /**
95      * Generates a command to request a part of a name of a module.
96      *
97      * @param partNumber 0..1
98      * @return the PCK command (without address header) as text
99      */
100     public static String requestModuleName(int partNumber) {
101         return "NMN" + (partNumber + 1);
102     }
103
104     /**
105      * Generates an output-port status request.
106      *
107      * @param outputId 0..3
108      * @return the PCK command (without address header) as text
109      * @throws LcnException if out of range
110      */
111     public static String requestOutputStatus(int outputId) throws LcnException {
112         if (outputId < 0 || outputId > 3) {
113             throw new LcnException();
114         }
115         return String.format("SMA%d", outputId + 1);
116     }
117
118     /**
119      * Generates a dim command for a single output-port.
120      *
121      * @param outputId 0..3
122      * @param percent 0..100
123      * @param rampMs ramp in milliseconds
124      * @return the PCK command (without address header) as text
125      * @throws LcnException if out of range
126      */
127     public static String dimOutput(int outputId, double percent, int rampMs) throws LcnException {
128         if (outputId < 0 || outputId > 3) {
129             throw new LcnException();
130         }
131         int rampNative = PckGenerator.timeToRampValue(rampMs);
132         int n = (int) Math.round(percent * 2);
133         if ((n % 2) == 0) { // Use the percent command (supported by all LCN-PCHK versions)
134             return String.format("A%dDI%03d%03d", outputId + 1, n / 2, rampNative);
135         } else { // We have a ".5" value. Use the native command (supported since LCN-PCHK 2.3)
136             return String.format("O%dDI%03d%03d", outputId + 1, n, rampNative);
137         }
138     }
139
140     /**
141      * Generates a command for setting the tunable white mode.
142      *
143      * @param mode 0..2
144      * @return the PCK command (without address header) as text
145      * @throws LcnException if out of range
146      */
147     public static String setTunableWhiteMode(int mode) throws LcnException {
148         if (mode < 0 || mode > 2) {
149             throw new LcnException();
150         }
151
152         return String.format("AW%d", mode);
153     }
154
155     /**
156      * Generates a dim command for all output-ports.
157      *
158      * Attention: This command is supported since module firmware version 180501 AND LCN-PCHK 2.61
159      *
160      * @param firstPercent dimmer value of the first output 0..100
161      * @param secondPercent dimmer value of the first output 0..100
162      * @param thirdPercent dimmer value of the first output 0..100
163      * @param fourthPercent dimmer value of the first output 0..100
164      * @param rampMs ramp in milliseconds
165      * @return the PCK command (without address header) as text
166      */
167     public static String dimAllOutputs(double firstPercent, double secondPercent, double thirdPercent,
168             double fourthPercent, int rampMs) {
169         long n1 = Math.round(firstPercent * 2);
170         long n2 = Math.round(secondPercent * 2);
171         long n3 = Math.round(thirdPercent * 2);
172         long n4 = Math.round(fourthPercent * 2);
173
174         return String.format("OY%03d%03d%03d%03d%03d", n1, n2, n3, n4, timeToRampValue(rampMs));
175     }
176
177     /**
178      * Generates a control command for switching all outputs ON or OFF with a fixed ramp of 0.5s.
179      *
180      * @param percent 0..100
181      * @return the PCK command (without address header) as text
182      */
183     public static String controlAllOutputs(double percent) {
184         return String.format("AH%03d", Math.round(percent));
185     }
186
187     /**
188      * Generates a control command for switching dimmer output 1 and 2 both ON or OFF with a fixed ramp of 0.5s or
189      * without ramp.
190      *
191      * @param on true, if outputs shall be switched on
192      * @param ramp true, if the ramp shall be 0.5s, else 0s
193      * @return the PCK command (without address header) as text
194      */
195     public static String controlOutputs12(boolean on, boolean ramp) {
196         int commandByte;
197         if (on) {
198             commandByte = ramp ? 0xC8 : 0xFD;
199         } else {
200             commandByte = ramp ? 0x00 : 0xFC;
201         }
202         return String.format("X2%03d%03d%03d", 1, commandByte, commandByte);
203     }
204
205     /**
206      * Generates a dim command for setting the brightness of dimmer output 1 and 2 with a fixed ramp of 0.5s.
207      *
208      * @param percent brightness of both outputs 0..100
209      * @return the PCK command (without address header) as text
210      */
211     public static String dimOutputs12(double percent) {
212         long localPercent = Math.round(percent);
213         return String.format("AY%03d%03d", localPercent, localPercent);
214     }
215
216     /**
217      * Let an output flicker.
218      *
219      * @param outputId output id 0..3
220      * @param depth flicker depth, the higher the deeper 0..2
221      * @param ramp the flicker speed 0..2
222      * @param count number of flashes 1..15
223      * @return the PCK command (without address header) as text
224      * @throws LcnException when the input values are out of range
225      */
226     public static String flickerOutput(int outputId, int depth, int ramp, int count) throws LcnException {
227         if (outputId < 0 || outputId > 3) {
228             throw new LcnException("Output number out of range");
229         }
230         if (count < 1 || count > 15) {
231             throw new LcnException("Number of flashes out of range");
232         }
233         String depthString;
234         switch (depth) {
235             case 0:
236                 depthString = "G";
237                 break;
238             case 1:
239                 depthString = "M";
240                 break;
241             case 2:
242                 depthString = "S";
243                 break;
244             default:
245                 throw new LcnException("Depth out of range");
246         }
247         String rampString;
248         switch (ramp) {
249             case 0:
250                 rampString = "L";
251                 break;
252             case 1:
253                 rampString = "M";
254                 break;
255             case 2:
256                 rampString = "S";
257                 break;
258             default:
259                 throw new LcnException("Ramp out of range");
260         }
261         return String.format("A%dFL%s%s%02d", outputId + 1, depthString, rampString, count);
262     }
263
264     /**
265      * Generates a command to change the value of an output-port.
266      *
267      * @param outputId 0..3
268      * @param percent -100..100
269      * @return the PCK command (without address header) as text
270      * @throws LcnException if out of range
271      */
272     public static String relOutput(int outputId, double percent) throws LcnException {
273         if (outputId < 0 || outputId > 3) {
274             throw new LcnException();
275         }
276         int n = (int) Math.round(percent * 2);
277         if ((n % 2) == 0) { // Use the percent command (supported by all LCN-PCHK versions)
278             return String.format("A%d%s%03d", outputId + 1, percent >= 0 ? "AD" : "SB", Math.abs(n / 2));
279         } else { // We have a ".5" value. Use the native command (supported since LCN-PCHK 2.3)
280             return String.format("O%d%s%03d", outputId + 1, percent >= 0 ? "AD" : "SB", Math.abs(n));
281         }
282     }
283
284     /**
285      * Generates a command that toggles a single output-port (on->off, off->on).
286      *
287      * @param outputId 0..3
288      * @param ramp see {@link PckGenerator#timeToRampValue(int)}
289      * @return the PCK command (without address header) as text
290      * @throws LcnException if out of range
291      */
292     public static String toggleOutput(int outputId, int ramp) throws LcnException {
293         if (outputId < 0 || outputId > 3) {
294             throw new LcnException();
295         }
296         return String.format("A%dTA%03d", outputId + 1, ramp);
297     }
298
299     /**
300      * Generates a command that toggles all output-ports (on->off, off->on).
301      *
302      * @param ramp see {@link PckGenerator#timeToRampValue(int)}
303      * @return the PCK command (without address header) as text
304      */
305     public static String toggleAllOutputs(int ramp) {
306         return String.format("AU%03d", ramp);
307     }
308
309     /**
310      * Generates a relays-status request.
311      *
312      * @return the PCK command (without address header) as text
313      */
314     public static String requestRelaysStatus() {
315         return "SMR";
316     }
317
318     /**
319      * Generates a command to control relays.
320      *
321      * @param states the 8 modifiers for the relay states
322      * @return the PCK command (without address header) as text
323      * @throws LcnException if out of range
324      */
325     public static String controlRelays(LcnDefs.RelayStateModifier[] states) throws LcnException {
326         if (states.length != 8) {
327             throw new LcnException();
328         }
329         StringBuilder ret = new StringBuilder("R8");
330         for (int i = 0; i < 8; ++i) {
331             switch (states[i]) {
332                 case ON:
333                     ret.append("1");
334                     break;
335                 case OFF:
336                     ret.append("0");
337                     break;
338                 case TOGGLE:
339                     ret.append("U");
340                     break;
341                 case NOCHANGE:
342                     ret.append("-");
343                     break;
344                 default:
345                     throw new LcnException();
346             }
347         }
348         return ret.toString();
349     }
350
351     /**
352      * Generates a command to control the position of roller shutters on relays.
353      *
354      * @param motorNumber of the roller shutter (0-based)
355      * @param percent of the entire roller shutter height
356      * @return the PCK command (without address header) as text
357      * @throws LcnException if out of range
358      */
359     public static String controlShutterPosition(int motorNumber, int percent) throws LcnException {
360         return controlShutter(motorNumber, percent, "JH");
361     }
362
363     /**
364      * Generates a command to control the slat angle of roller shutters on relays.
365      *
366      * @param motorNumber of the roller shutter (0-based)
367      * @param percent of the slat angle
368      * @return the PCK command (without address header) as text
369      * @throws LcnException if out of range
370      */
371     public static String controlShutterSlatAngle(int motorNumber, int percent) throws LcnException {
372         return controlShutter(motorNumber, percent, "JW");
373     }
374
375     private static String controlShutter(int motorNumber, int percent, String command) throws LcnException {
376         if (motorNumber < 0 || motorNumber >= 4) {
377             throw new LcnException("Roller shutter (relay) motor number out of range: " + motorNumber);
378         }
379
380         if (percent < 0 || percent > 100) {
381             throw new LcnException("Roller shutter (relay) position/angle out of range (percent): " + percent);
382         }
383
384         return String.format("%s%03d%03d", command, percent, 1 << motorNumber);
385     }
386
387     /**
388      * Generates a binary-sensors status request.
389      *
390      * @return the PCK command (without address header) as text
391      */
392     public static String requestBinSensorsStatus() {
393         return "SMB";
394     }
395
396     /**
397      * Generates a command that sets a variable absolute.
398      *
399      * @param number regulator number 0..1
400      * @param value the absolute value to set
401      * @return the PCK command (without address header) as text
402      */
403     public static String setSetpointAbsolute(int number, int value) {
404         int internalValue = value;
405         // Set absolute (not in PCK yet)
406         int b1 = number << 6; // 01000000
407         b1 |= 0x20; // xx10xxxx (set absolute)
408         if (value < 1000) {
409             internalValue = 1000 - internalValue;
410             b1 |= 8;
411         } else {
412             internalValue -= 1000;
413         }
414         b1 |= (internalValue >> 8) & 0x0f; // xxxx1111
415         int b2 = internalValue & 0xff;
416         return String.format("X2%03d%03d%03d", 30, b1, b2);
417     }
418
419     /**
420      * Generates a command to change the regulator mode.
421      *
422      * @param number regulator number 0..1
423      * @param cooling true=cooling, false=heating
424      * @return the PCK command (without address header) as text
425      * @throws LcnException
426      */
427     public static String setRVarMode(int number, boolean cooling) throws LcnException {
428         String regulator;
429         switch (number) {
430             case 0:
431                 regulator = "A";
432                 break;
433             case 1:
434                 regulator = "B";
435                 break;
436             default:
437                 throw new LcnException();
438         }
439
440         return "RE" + regulator + "T" + (cooling ? "C" : "H");
441     }
442
443     /**
444      * Generates a command to change the value of a variable.
445      *
446      * @param variable the target variable to change
447      * @param type the reference-point
448      * @param value the native LCN value to add/subtract (can be negative)
449      * @return the PCK command (without address header) as text
450      */
451     public static String setVariableRelative(Variable variable, LcnDefs.RelVarRef type, int value) {
452         if (variable.getNumber() == 0) {
453             // Old command for variable 1 / T-var (compatible with all modules)
454             return String.format("Z%s%d", value >= 0 ? "A" : "S", Math.abs(value));
455         } else { // New command for variable 1-12 (compatible with all modules, since LCN-PCHK 2.8)
456             return String.format("Z%s%03d%d", value >= 0 ? "+" : "-", variable.getNumber() + 1, Math.abs(value));
457         }
458     }
459
460     /**
461      * Generates a command the change the value of a regulator setpoint relative.
462      *
463      * @param number 0..1
464      * @param type relative to the current or to the programmed value
465      * @param value the relative value -4000..+4000
466      * @return the PCK command (without address header) as text
467      */
468     public static String setSetpointRelative(int number, LcnDefs.RelVarRef type, int value) {
469         return String.format("RE%sS%s%s%d", number == 0 ? "A" : "B", type == LcnDefs.RelVarRef.CURRENT ? "A" : "P",
470                 value >= 0 ? "+" : "-", Math.abs(value));
471     }
472
473     /**
474      * Generates a command the change the value of a threshold relative.
475      *
476      * @param variable the threshold to change
477      * @param type relative to the current or to the programmed value
478      * @param value the relative value -4000..+4000
479      * @param is2013 true, if the LCN module's firmware is equal to or newer than 2013
480      * @return the PCK command (without address header) as text
481      */
482     public static String setThresholdRelative(Variable variable, LcnDefs.RelVarRef type, int value, boolean is2013)
483             throws LcnException {
484         if (is2013) { // New command for registers 1-4 (since 170206, LCN-PCHK 2.8)
485             return String.format("SS%s%04d%sR%d%d", type == LcnDefs.RelVarRef.CURRENT ? "R" : "E", Math.abs(value),
486                     value >= 0 ? "A" : "S", variable.getNumber() + 1, variable.getThresholdNumber().get() + 1);
487         } else if (variable.getNumber() == 0) { // Old command for register 1 (before 170206)
488             return String.format("SS%s%04d%s%s%s%s%s%s", type == LcnDefs.RelVarRef.CURRENT ? "R" : "E", Math.abs(value),
489                     value >= 0 ? "A" : "S", variable.getThresholdNumber().get() == 0 ? "1" : "0",
490                     variable.getThresholdNumber().get() == 1 ? "1" : "0",
491                     variable.getThresholdNumber().get() == 2 ? "1" : "0",
492                     variable.getThresholdNumber().get() == 3 ? "1" : "0",
493                     variable.getThresholdNumber().get() == 4 ? "1" : "0");
494         } else {
495             throw new LcnException(
496                     "Module does not have threshold register " + (variable.getThresholdNumber().get() + 1));
497         }
498     }
499
500     /**
501      * Generates a variable value request.
502      *
503      * @param variable the variable to request
504      * @param firmwareVersion the target module's firmware version
505      * @return the PCK command (without address header) as text
506      * @throws LcnException if command is not supported
507      */
508     public static String requestVarStatus(Variable variable, int firmwareVersion) throws LcnException {
509         if (firmwareVersion >= LcnBindingConstants.FIRMWARE_2013) {
510             int id = variable.getNumber();
511             switch (variable.getType()) {
512                 case UNKNOWN:
513                     throw new LcnException("Variable unknown");
514                 case VARIABLE:
515                     return "MWT" + (id + 1);
516                 case REGULATOR:
517                     return "MWS" + (id + 1);
518                 case THRESHOLD:
519                     return "SE" + (id + 1); // Whole register
520                 case S0INPUT:
521                     return "MWC" + (id + 1);
522             }
523             throw new LcnException("Unsupported variable type: " + variable);
524         } else {
525             switch (variable) {
526                 case VARIABLE1:
527                     return "MWV";
528                 case VARIABLE2:
529                     return "MWTA";
530                 case VARIABLE3:
531                     return "MWTB";
532                 case RVARSETPOINT1:
533                     return "MWSA";
534                 case RVARSETPOINT2:
535                     return "MWSB";
536                 case THRESHOLDREGISTER11:
537                 case THRESHOLDREGISTER12:
538                 case THRESHOLDREGISTER13:
539                 case THRESHOLDREGISTER14:
540                 case THRESHOLDREGISTER15:
541                     return "SL1"; // Whole register
542                 default:
543                     throw new LcnException("Unsupported variable type: " + variable);
544             }
545         }
546     }
547
548     /**
549      * Generates a request for LED and logic-operations states.
550      *
551      * @return the PCK command (without address header) as text
552      */
553     public static String requestLedsAndLogicOpsStatus() {
554         return "SMT";
555     }
556
557     /**
558      * Generates a command to the set the state of a single LED.
559      *
560      * @param ledId 0..11
561      * @param state the state to set
562      * @return the PCK command (without address header) as text
563      * @throws LcnException if out of range
564      */
565     public static String controlLed(int ledId, LcnDefs.LedStatus state) throws LcnException {
566         if (ledId < 0 || ledId > 11) {
567             throw new LcnException();
568         }
569         return String.format("LA%03d%s", ledId + 1, state == LcnDefs.LedStatus.OFF ? "A"
570                 : state == LcnDefs.LedStatus.ON ? "E" : state == LcnDefs.LedStatus.BLINK ? "B" : "F");
571     }
572
573     /**
574      * Generates a command to send LCN keys.
575      *
576      * @param cmds the 4 concrete commands to send for the tables (A-D)
577      * @param keys the tables' 8 key-states (true means "send")
578      * @return the PCK command (without address header) as text
579      * @throws LcnException if out of range
580      */
581     public static String sendKeys(LcnDefs.SendKeyCommand[] cmds, boolean[] keys) throws LcnException {
582         if (cmds.length != 4 || keys.length != 8) {
583             throw new LcnException();
584         }
585         StringBuilder ret = new StringBuilder("TS");
586         for (int i = 0; i < 4; ++i) {
587             switch (cmds[i]) {
588                 case HIT:
589                     ret.append("K");
590                     break;
591                 case MAKE:
592                     ret.append("L");
593                     break;
594                 case BREAK:
595                     ret.append("O");
596                     break;
597                 case DONTSEND:
598                     // By skipping table D (if it is not used), we use the old command
599                     // for table A-C which is compatible with older LCN modules
600                     if (i < 3) {
601                         ret.append("-");
602                     }
603                     break;
604                 default:
605                     throw new LcnException();
606             }
607         }
608         for (int i = 0; i < 8; ++i) {
609             ret.append(keys[i] ? "1" : "0");
610         }
611         return ret.toString();
612     }
613
614     /**
615      * Generates a command to send LCN keys deferred / delayed.
616      *
617      * @param tableId 0(A)..3(D)
618      * @param time the delay time
619      * @param timeUnit the time unit
620      * @param keys the key-states (true means "send")
621      * @return the PCK command (without address header) as text
622      * @throws LcnException if out of range
623      */
624     public static String sendKeysHitDefered(int tableId, int time, LcnDefs.TimeUnit timeUnit, boolean[] keys)
625             throws LcnException {
626         if (tableId < 0 || tableId > 3 || keys.length != 8) {
627             throw new LcnException();
628         }
629         StringBuilder ret = new StringBuilder("TV");
630         switch (tableId) {
631             case 0:
632                 ret.append("A");
633                 break;
634             case 1:
635                 ret.append("B");
636                 break;
637             case 2:
638                 ret.append("C");
639                 break;
640             case 3:
641                 ret.append("D");
642                 break;
643             default:
644                 throw new LcnException();
645         }
646         ret.append(String.format("%03d", time));
647         switch (timeUnit) {
648             case SECONDS:
649                 if (time < 1 || time > 60) {
650                     throw new LcnException();
651                 }
652                 ret.append("S");
653                 break;
654             case MINUTES:
655                 if (time < 1 || time > 90) {
656                     throw new LcnException();
657                 }
658                 ret.append("M");
659                 break;
660             case HOURS:
661                 if (time < 1 || time > 50) {
662                     throw new LcnException();
663                 }
664                 ret.append("H");
665                 break;
666             case DAYS:
667                 if (time < 1 || time > 45) {
668                     throw new LcnException();
669                 }
670                 ret.append("D");
671                 break;
672             default:
673                 throw new LcnException();
674         }
675         for (int i = 0; i < 8; ++i) {
676             ret.append(keys[i] ? "1" : "0");
677         }
678         return ret.toString();
679     }
680
681     /**
682      * Generates a request for key-lock states.
683      * Always requests table A-D. Supported since LCN-PCHK 2.8.
684      *
685      * @return the PCK command (without address header) as text
686      */
687     public static String requestKeyLocksStatus() {
688         return "STX";
689     }
690
691     /**
692      * Generates a command to lock keys.
693      *
694      * @param tableId 0(A)..3(D)
695      * @param states the 8 key-lock modifiers
696      * @return the PCK command (without address header) as text
697      * @throws LcnException if out of range
698      */
699     public static String lockKeys(int tableId, LcnDefs.KeyLockStateModifier[] states) throws LcnException {
700         if (tableId < 0 || tableId > 3 || states.length != 8) {
701             throw new LcnException();
702         }
703         StringBuilder ret = new StringBuilder(
704                 String.format("TX%s", tableId == 0 ? "A" : tableId == 1 ? "B" : tableId == 2 ? "C" : "D"));
705         for (int i = 0; i < 8; ++i) {
706             switch (states[i]) {
707                 case ON:
708                     ret.append("1");
709                     break;
710                 case OFF:
711                     ret.append("0");
712                     break;
713                 case TOGGLE:
714                     ret.append("U");
715                     break;
716                 case NOCHANGE:
717                     ret.append("-");
718                     break;
719                 default:
720                     throw new LcnException();
721             }
722         }
723         return ret.toString();
724     }
725
726     /**
727      * Generates a command to lock keys for table A temporary.
728      * There is no hardware-support for locking tables B-D.
729      *
730      * @param time the lock time
731      * @param timeUnit the time unit
732      * @param keys the 8 key-lock states (true means lock)
733      * @return the PCK command (without address header) as text
734      * @throws LcnException if out of range
735      */
736     public static String lockKeyTabATemporary(int time, LcnDefs.TimeUnit timeUnit, boolean[] keys) throws LcnException {
737         if (keys.length != 8) {
738             throw new LcnException();
739         }
740         StringBuilder ret = new StringBuilder(String.format("TXZA%03d", time));
741         switch (timeUnit) {
742             case SECONDS:
743                 if (time < 1 || time > 60) {
744                     throw new LcnException();
745                 }
746                 ret.append("S");
747                 break;
748             case MINUTES:
749                 if (time < 1 || time > 90) {
750                     throw new LcnException();
751                 }
752                 ret.append("M");
753                 break;
754             case HOURS:
755                 if (time < 1 || time > 50) {
756                     throw new LcnException();
757                 }
758                 ret.append("H");
759                 break;
760             case DAYS:
761                 if (time < 1 || time > 45) {
762                     throw new LcnException();
763                 }
764                 ret.append("D");
765                 break;
766             default:
767                 throw new LcnException();
768         }
769         for (int i = 0; i < 8; ++i) {
770             ret.append(keys[i] ? "1" : "0");
771         }
772         return ret.toString();
773     }
774
775     /**
776      * Generates the command header / start for sending dynamic texts.
777      * Used by LCN-GTxD periphery (supports 4 text rows).
778      * To complete the command, the text to send must be appended (UTF-8 encoding).
779      * Texts are split up into up to 5 parts with 12 "UTF-8 bytes" each.
780      *
781      * @param row 0..3
782      * @param part 0..4
783      * @return the PCK command (without address header) as text
784      * @throws LcnException if out of range
785      */
786     public static String dynTextHeader(int row, int part) throws LcnException {
787         if (row < 0 || row > 3 || part < 0 || part > 4) {
788             throw new LcnException("Row number is out of range: " + (row + 1));
789         }
790         return String.format("GTDT%d%d", row + 1, part + 1);
791     }
792
793     /**
794      * Generates a command to lock a regulator.
795      *
796      * @param regId 0..1
797      * @param state the lock state
798      * @return the PCK command (without address header) as text
799      * @throws LcnException if out of range
800      */
801     public static String lockRegulator(int regId, boolean state) throws LcnException {
802         if (regId < 0 || regId > 1) {
803             throw new LcnException();
804         }
805         return String.format("RE%sX%s", regId == 0 ? "A" : "B", state ? "S" : "A");
806     }
807
808     /**
809      * Generates a command to start a relay timer
810      *
811      * @param relayNumber number of relay (1..8)
812      * @param duration duration in milliseconds
813      * @return the PCK command (without address header) as text
814      * @throws LcnException if out of range
815      */
816     public static String startRelayTimer(int relayNumber, double duration) throws LcnException {
817         if (relayNumber < 1 || relayNumber > 8 || duration < 30 || duration > 240960) {
818             throw new LcnException();
819         }
820         StringBuilder command = new StringBuilder("R8T");
821         command.append(String.format("%03d", convertMsecToLCNTimer(duration)));
822         StringBuilder data = new StringBuilder("--------");
823         data.setCharAt(relayNumber - 1, '1');
824         command.append(data);
825         return command.toString();
826     }
827
828     /**
829      * Generates a command to set the beeping sound volume.
830      *
831      * @param volume the sound volume
832      * @return the PCK command (without address header) as text
833      * @throws LcnException if out of range
834      */
835     public static String setBeepVolume(double volume) throws LcnException {
836         if (volume < 0 || volume > 100) {
837             throw new LcnException();
838         }
839
840         return String.format("PIV%03d", Math.round(volume));
841     }
842
843     /**
844      * Generates a command to let the beeper connected to the LCN module beep.
845      *
846      * @param tonality N=normal, S=special, 1-7 tonalities 1-7.
847      * @param count count number of beeps.
848      * @return the PCK command (without address header) as text
849      * @throws LcnException if out of range
850      */
851     public static String beep(String tonality, int count) throws LcnException {
852         LcnBindingConstants.ALLOWED_BEEP_TONALITIES.stream() //
853                 .filter(t -> t.equals(tonality)) //
854                 .findAny() //
855                 .orElseThrow(LcnException::new);
856
857         if (count < 0) {
858             throw new LcnException();
859         }
860
861         return String.format("PI%s%d", tonality, Math.min(count, 50));
862     }
863
864     /**
865      * Generates a null command, used for broadcast messages.
866      *
867      * @return the PCK command (without address header) as text
868      */
869     public static String nullCommand() {
870         return "LEER";
871     }
872
873     /**
874      * Converts the given time into an LCN ramp value.
875      *
876      * @param timeMSec the time in milliseconds
877      * @return the (LCN-internal) ramp value (0..250)
878      */
879     private static int timeToRampValue(int timeMSec) {
880         int ret;
881         if (timeMSec < 250) {
882             ret = 0;
883         } else if (timeMSec < 500) {
884             ret = 1;
885         } else if (timeMSec < 660) {
886             ret = 2;
887         } else if (timeMSec < 1000) {
888             ret = 3;
889         } else if (timeMSec < 1400) {
890             ret = 4;
891         } else if (timeMSec < 2000) {
892             ret = 5;
893         } else if (timeMSec < 3000) {
894             ret = 6;
895         } else if (timeMSec < 4000) {
896             ret = 7;
897         } else if (timeMSec < 5000) {
898             ret = 8;
899         } else if (timeMSec < 6000) {
900             ret = 9;
901         } else {
902             ret = (timeMSec / 1000 - 6) / 2 + 10;
903             if (ret > 250) {
904                 ret = 250;
905                 LOGGER.warn("Ramp value is too high. Limiting value to 486s.");
906             }
907         }
908         return ret;
909     }
910
911     /**
912      * Converts duration in milliseconds to lcntimer value
913      * Source: https://www.symcon.de/forum/threads/38603-LCN-Relais-Kurzzeit-Timer-umrechnen
914      *
915      * @param ms time in milliseconds
916      * @return lcn timer value
917      */
918     private static int convertMsecToLCNTimer(double ms) {
919         Integer lcntimer = -1;
920         if (ms >= 0 && ms <= 240960) {
921             double a = ms / 1000 / 0.03;
922             double b = (a / 32.0) + 1.0;
923             double c = Math.log(b) / Math.log(2);
924             double mod = Math.floor(c);
925             double faktor = 32 * (Math.pow(2, mod) - 1);
926             double offset = (a - faktor) / Math.pow(2, mod);
927             lcntimer = (int) (offset + mod * 32);
928         } else {
929             LOGGER.warn("Timer not in [0,240960] ms");
930         }
931         return lcntimer;
932     }
933 }