]> git.basschouten.com Git - openhab-addons.git/blob
49bb3335290a9789db8fec61c78fab1b1ff5b9fc
[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.keba.internal.handler;
14
15 import static org.openhab.binding.keba.internal.KebaBindingConstants.*;
16
17 import java.io.IOException;
18 import java.math.BigDecimal;
19 import java.net.InetSocketAddress;
20 import java.net.Socket;
21 import java.net.SocketAddress;
22 import java.nio.ByteBuffer;
23 import java.util.Map;
24 import java.util.Map.Entry;
25 import java.util.Objects;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28
29 import javax.measure.quantity.Dimensionless;
30 import javax.measure.quantity.ElectricCurrent;
31 import javax.measure.quantity.ElectricPotential;
32 import javax.measure.quantity.Energy;
33 import javax.measure.quantity.Power;
34 import javax.measure.quantity.Time;
35
36 import org.openhab.binding.keba.internal.KebaBindingConstants.KebaSeries;
37 import org.openhab.binding.keba.internal.KebaBindingConstants.KebaType;
38 import org.openhab.core.cache.ExpiringCacheMap;
39 import org.openhab.core.config.core.Configuration;
40 import org.openhab.core.library.types.DecimalType;
41 import org.openhab.core.library.types.IncreaseDecreaseType;
42 import org.openhab.core.library.types.OnOffType;
43 import org.openhab.core.library.types.QuantityType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.library.unit.Units;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.binding.BaseThingHandler;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.State;
54 import org.openhab.core.util.StringUtils;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
57
58 import com.google.gson.JsonElement;
59 import com.google.gson.JsonObject;
60 import com.google.gson.JsonParseException;
61 import com.google.gson.JsonParser;
62
63 /**
64  * The {@link KeContactHandler} is responsible for handling commands, which
65  * are sent to one of the channels.
66  *
67  * @author Karel Goderis - Initial contribution
68  */
69 public class KeContactHandler extends BaseThingHandler {
70
71     public static final String IP_ADDRESS = "ipAddress";
72     public static final String POLLING_REFRESH_INTERVAL = "refreshInterval";
73     public static final int POLLING_REFRESH_INTERVAL_DEFAULT = 15;
74     public static final int REPORT_INTERVAL = 3000;
75     public static final int BUFFER_SIZE = 1024;
76     public static final int REMOTE_PORT_NUMBER = 7090;
77     private static final String CACHE_REPORT_1 = "REPORT_1";
78     private static final String CACHE_REPORT_2 = "REPORT_2";
79     private static final String CACHE_REPORT_3 = "REPORT_3";
80     private static final String CACHE_REPORT_100 = "REPORT_100";
81     public static final int SOCKET_TIME_OUT_MS = 3000;
82     public static final int SOCKET_CHECK_PORT_NUMBER = 80;
83
84     private final Logger logger = LoggerFactory.getLogger(KeContactHandler.class);
85
86     private final KeContactTransceiver transceiver;
87
88     private ScheduledFuture<?> pollingJob;
89     private ExpiringCacheMap<String, ByteBuffer> cache;
90
91     private int maxPresetCurrent = 0;
92     private int maxSystemCurrent = 63000;
93     private KebaType type;
94     private KebaSeries series;
95     private int lastState = -1; // trigger a report100 at startup
96     private boolean isReport100needed = true;
97
98     public KeContactHandler(Thing thing, KeContactTransceiver transceiver) {
99         super(thing);
100         this.transceiver = transceiver;
101     }
102
103     @Override
104     public void initialize() {
105         try {
106             if (isKebaReachable()) {
107                 transceiver.registerHandler(this);
108
109                 int refreshInterval = getRefreshInterval();
110                 cache = new ExpiringCacheMap<>(Math.max(refreshInterval - 5, 0) * 1000);
111
112                 cache.put(CACHE_REPORT_1, () -> transceiver.send("report 1", getHandler()));
113                 cache.put(CACHE_REPORT_2, () -> transceiver.send("report 2", getHandler()));
114                 cache.put(CACHE_REPORT_3, () -> transceiver.send("report 3", getHandler()));
115                 cache.put(CACHE_REPORT_100, () -> transceiver.send("report 100", getHandler()));
116
117                 if (pollingJob == null || pollingJob.isCancelled()) {
118                     pollingJob = scheduler.scheduleWithFixedDelay(this::pollingRunnable, 0, refreshInterval,
119                             TimeUnit.SECONDS);
120                 }
121             } else {
122                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
123                         "IP address or port number not set");
124             }
125         } catch (IOException e) {
126             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
127                     "Exception during initialization of binding: " + e.toString());
128         }
129     }
130
131     private boolean isKebaReachable() throws IOException {
132         boolean isReachable = false;
133         SocketAddress sockAddr = new InetSocketAddress(getIPAddress(), SOCKET_CHECK_PORT_NUMBER);
134         Socket socket = new Socket();
135         try {
136             socket.connect(sockAddr, SOCKET_TIME_OUT_MS);
137             isReachable = true;
138         } finally {
139             socket.close();
140         }
141         logger.debug("isKebaReachable() returns {}", isReachable);
142         return isReachable;
143     }
144
145     @Override
146     public void dispose() {
147         if (pollingJob != null && !pollingJob.isCancelled()) {
148             pollingJob.cancel(true);
149             pollingJob = null;
150         }
151
152         transceiver.unRegisterHandler(this);
153     }
154
155     public String getIPAddress() {
156         return getConfig().get(IP_ADDRESS) != null ? (String) getConfig().get(IP_ADDRESS) : "";
157     }
158
159     public int getRefreshInterval() {
160         return getConfig().get(POLLING_REFRESH_INTERVAL) != null
161                 ? ((BigDecimal) getConfig().get(POLLING_REFRESH_INTERVAL)).intValue()
162                 : POLLING_REFRESH_INTERVAL_DEFAULT;
163     }
164
165     private KeContactHandler getHandler() {
166         return this;
167     }
168
169     @Override
170     public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, String description) {
171         super.updateStatus(status, statusDetail, description);
172     }
173
174     @Override
175     protected Configuration getConfig() {
176         return super.getConfig();
177     }
178
179     private void pollingRunnable() {
180         try {
181             logger.debug("Running pollingRunnable to connect Keba wallbox");
182             long stamp = System.currentTimeMillis();
183             if (!isKebaReachable()) {
184                 logger.debug("isKebaReachable() timed out after '{}' milliseconds", System.currentTimeMillis() - stamp);
185                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
186                         "A timeout occurred while polling the charging station");
187             } else {
188                 ByteBuffer response = cache.get(CACHE_REPORT_1);
189                 if (response == null) {
190                     logger.debug("Missing response from Keba station for 'report 1'");
191                 } else {
192                     onData(response);
193                 }
194
195                 Thread.sleep(REPORT_INTERVAL);
196
197                 response = cache.get(CACHE_REPORT_2);
198                 if (response == null) {
199                     logger.debug("Missing response from Keba station for 'report 2'");
200                 } else {
201                     onData(response);
202                 }
203
204                 Thread.sleep(REPORT_INTERVAL);
205
206                 response = cache.get(CACHE_REPORT_3);
207                 if (response == null) {
208                     logger.debug("Missing response from Keba station for 'report 3'");
209                 } else {
210                     onData(response);
211                 }
212
213                 if (isReport100needed) {
214                     Thread.sleep(REPORT_INTERVAL);
215
216                     response = cache.get(CACHE_REPORT_100);
217                     if (response == null) {
218                         logger.debug("Missing response from Keba station for 'report 100'");
219                     } else {
220                         onData(response);
221                     }
222                     isReport100needed = false;
223                 }
224             }
225         } catch (IOException e) {
226             logger.debug("An error occurred while polling the KEBA KeContact '{}': {}", getThing().getUID(),
227                     e.getMessage(), e);
228             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
229                     "An error occurred while polling the charging station");
230         } catch (InterruptedException e) {
231             logger.debug("Polling job has been interrupted for handler of thing '{}'.", getThing().getUID());
232         }
233     }
234
235     protected void onData(ByteBuffer byteBuffer) {
236         if (getThing().getStatus() != ThingStatus.ONLINE) {
237             updateStatus(ThingStatus.ONLINE);
238         }
239
240         String response = new String(byteBuffer.array(), 0, byteBuffer.limit());
241         response = Objects.requireNonNull(StringUtils.chomp(response));
242
243         if (response.contains("TCH-OK")) {
244             // ignore confirmation messages which are not JSON
245             return;
246         }
247
248         try {
249             JsonObject readObject = JsonParser.parseString(response).getAsJsonObject();
250
251             for (Entry<String, JsonElement> entry : readObject.entrySet()) {
252                 switch (entry.getKey()) {
253                     case "Product": {
254                         Map<String, String> properties = editProperties();
255                         String product = entry.getValue().getAsString().trim();
256                         properties.put(CHANNEL_MODEL, product);
257                         updateProperties(properties);
258                         if (product.contains("P20")) {
259                             type = KebaType.P20;
260                         } else if (product.contains("P30")) {
261                             type = KebaType.P30;
262                         }
263                         series = KebaSeries.getSeries(product.substring(13, 14).charAt(0));
264                         break;
265                     }
266                     case "Serial": {
267                         Map<String, String> properties = editProperties();
268                         properties.put(CHANNEL_SERIAL, entry.getValue().getAsString());
269                         updateProperties(properties);
270                         break;
271                     }
272                     case "Firmware": {
273                         Map<String, String> properties = editProperties();
274                         properties.put(CHANNEL_FIRMWARE, entry.getValue().getAsString());
275                         updateProperties(properties);
276                         break;
277                     }
278                     case "Plug": {
279                         int state = entry.getValue().getAsInt();
280                         switch (state) {
281                             case 0: {
282                                 updateState(CHANNEL_WALLBOX, OnOffType.OFF);
283                                 updateState(CHANNEL_VEHICLE, OnOffType.OFF);
284                                 updateState(CHANNEL_PLUG_LOCKED, OnOffType.OFF);
285                                 break;
286                             }
287                             case 1: {
288                                 updateState(CHANNEL_WALLBOX, OnOffType.ON);
289                                 updateState(CHANNEL_VEHICLE, OnOffType.OFF);
290                                 updateState(CHANNEL_PLUG_LOCKED, OnOffType.OFF);
291                                 break;
292                             }
293                             case 3: {
294                                 updateState(CHANNEL_WALLBOX, OnOffType.ON);
295                                 updateState(CHANNEL_VEHICLE, OnOffType.OFF);
296                                 updateState(CHANNEL_PLUG_LOCKED, OnOffType.ON);
297                                 break;
298                             }
299                             case 5: {
300                                 updateState(CHANNEL_WALLBOX, OnOffType.ON);
301                                 updateState(CHANNEL_VEHICLE, OnOffType.ON);
302                                 updateState(CHANNEL_PLUG_LOCKED, OnOffType.OFF);
303                                 break;
304                             }
305                             case 7: {
306                                 updateState(CHANNEL_WALLBOX, OnOffType.ON);
307                                 updateState(CHANNEL_VEHICLE, OnOffType.ON);
308                                 updateState(CHANNEL_PLUG_LOCKED, OnOffType.ON);
309                                 break;
310                             }
311                         }
312                         break;
313                     }
314                     case "State": {
315                         int state = entry.getValue().getAsInt();
316                         State newState = new DecimalType(state);
317                         updateState(CHANNEL_STATE, newState);
318                         if (lastState != state) {
319                             // the state is different from the last one, so we will trigger a report100
320                             isReport100needed = true;
321                             lastState = state;
322                         }
323                         break;
324                     }
325                     case "Enable sys": {
326                         int state = entry.getValue().getAsInt();
327                         switch (state) {
328                             case 1: {
329                                 updateState(CHANNEL_ENABLED, OnOffType.ON);
330                                 break;
331                             }
332                             default: {
333                                 updateState(CHANNEL_ENABLED, OnOffType.OFF);
334                                 break;
335                             }
336                         }
337                         break;
338                     }
339                     case "Curr HW": {
340                         int state = entry.getValue().getAsInt();
341                         maxSystemCurrent = state;
342                         State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
343                         updateState(CHANNEL_MAX_SYSTEM_CURRENT, newState);
344                         if (maxSystemCurrent != 0) {
345                             if (maxSystemCurrent < maxPresetCurrent) {
346                                 transceiver.send("curr " + maxSystemCurrent, this);
347                                 updateState(CHANNEL_MAX_PRESET_CURRENT,
348                                         new QuantityType<ElectricCurrent>(maxSystemCurrent / 1000.0, Units.AMPERE));
349                                 updateState(CHANNEL_MAX_PRESET_CURRENT_RANGE, new QuantityType<Dimensionless>(
350                                         (maxSystemCurrent - 6000) * 100 / (maxSystemCurrent - 6000), Units.PERCENT));
351                             }
352                         } else {
353                             logger.debug("maxSystemCurrent is 0. Ignoring.");
354                         }
355                         break;
356                     }
357                     case "Curr user": {
358                         int state = entry.getValue().getAsInt();
359                         maxPresetCurrent = state;
360                         State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
361                         updateState(CHANNEL_MAX_PRESET_CURRENT, newState);
362                         if (maxSystemCurrent != 0) {
363                             updateState(CHANNEL_MAX_PRESET_CURRENT_RANGE, new QuantityType<Dimensionless>(
364                                     Math.min(100, (state - 6000) * 100 / (maxSystemCurrent - 6000)), Units.PERCENT));
365                         }
366                         break;
367                     }
368                     case "Curr FS": {
369                         int state = entry.getValue().getAsInt();
370                         State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
371                         updateState(CHANNEL_FAILSAFE_CURRENT, newState);
372                         break;
373                     }
374                     case "Max curr": {
375                         int state = entry.getValue().getAsInt();
376                         maxPresetCurrent = state;
377                         State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
378                         updateState(CHANNEL_PILOT_CURRENT, newState);
379                         break;
380                     }
381                     case "Max curr %": {
382                         int state = entry.getValue().getAsInt();
383                         State newState = new QuantityType<Dimensionless>(state / 10.0, Units.PERCENT);
384                         updateState(CHANNEL_PILOT_PWM, newState);
385                         break;
386                     }
387                     case "Output": {
388                         int state = entry.getValue().getAsInt();
389                         switch (state) {
390                             case 1: {
391                                 updateState(CHANNEL_OUTPUT, OnOffType.ON);
392                                 break;
393                             }
394                             default: {
395                                 updateState(CHANNEL_OUTPUT, OnOffType.OFF);
396                                 break;
397                             }
398                         }
399                         break;
400                     }
401                     case "Input": {
402                         int state = entry.getValue().getAsInt();
403                         switch (state) {
404                             case 1: {
405                                 updateState(CHANNEL_INPUT, OnOffType.ON);
406                                 break;
407                             }
408                             default: {
409                                 updateState(CHANNEL_INPUT, OnOffType.OFF);
410                                 break;
411                             }
412                         }
413                         break;
414                     }
415                     case "Sec": {
416                         long state = entry.getValue().getAsLong();
417                         State newState = new QuantityType<Time>(state, Units.SECOND);
418                         updateState(CHANNEL_UPTIME, newState);
419                         break;
420                     }
421                     case "U1": {
422                         int state = entry.getValue().getAsInt();
423                         State newState = new QuantityType<ElectricPotential>(state, Units.VOLT);
424                         updateState(CHANNEL_U1, newState);
425                         break;
426                     }
427                     case "U2": {
428                         int state = entry.getValue().getAsInt();
429                         State newState = new QuantityType<ElectricPotential>(state, Units.VOLT);
430                         updateState(CHANNEL_U2, newState);
431                         break;
432                     }
433                     case "U3": {
434                         int state = entry.getValue().getAsInt();
435                         State newState = new QuantityType<ElectricPotential>(state, Units.VOLT);
436                         updateState(CHANNEL_U3, newState);
437                         break;
438                     }
439                     case "I1": {
440                         int state = entry.getValue().getAsInt();
441                         State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
442                         updateState(CHANNEL_I1, newState);
443                         break;
444                     }
445                     case "I2": {
446                         int state = entry.getValue().getAsInt();
447                         State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
448                         updateState(CHANNEL_I2, newState);
449                         break;
450                     }
451                     case "I3": {
452                         int state = entry.getValue().getAsInt();
453                         State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
454                         updateState(CHANNEL_I3, newState);
455                         break;
456                     }
457                     case "P": {
458                         long state = entry.getValue().getAsLong();
459                         State newState = new QuantityType<Power>(state / 1000.0, Units.WATT);
460                         updateState(CHANNEL_POWER, newState);
461                         break;
462                     }
463                     case "PF": {
464                         int state = entry.getValue().getAsInt();
465                         State newState = new QuantityType<Dimensionless>(state / 10.0, Units.PERCENT);
466                         updateState(CHANNEL_POWER_FACTOR, newState);
467                         break;
468                     }
469                     case "E pres": {
470                         long state = entry.getValue().getAsLong();
471                         State newState = new QuantityType<Energy>(state / 10.0, Units.WATT_HOUR);
472                         updateState(CHANNEL_SESSION_CONSUMPTION, newState);
473                         break;
474                     }
475                     case "E total": {
476                         long state = entry.getValue().getAsLong();
477                         State newState = new QuantityType<Energy>(state / 10.0, Units.WATT_HOUR);
478                         updateState(CHANNEL_TOTAL_CONSUMPTION, newState);
479                         break;
480                     }
481                     case "AuthON": {
482                         int state = entry.getValue().getAsInt();
483                         State newState = new DecimalType(state);
484                         updateState(CHANNEL_AUTHON, newState);
485                         break;
486                     }
487                     case "Authreq": {
488                         int state = entry.getValue().getAsInt();
489                         State newState = new DecimalType(state);
490                         updateState(CHANNEL_AUTHREQ, newState);
491                         break;
492                     }
493                     case "RFID tag": {
494                         String state = entry.getValue().getAsString().trim();
495                         State newState = new StringType(state);
496                         updateState(CHANNEL_SESSION_RFID_TAG, newState);
497                         break;
498                     }
499                     case "RFID class": {
500                         String state = entry.getValue().getAsString().trim();
501                         State newState = new StringType(state);
502                         updateState(CHANNEL_SESSION_RFID_CLASS, newState);
503                         break;
504                     }
505                     case "Session ID": {
506                         int state = entry.getValue().getAsInt();
507                         State newState = new DecimalType(state);
508                         updateState(CHANNEL_SESSION_SESSION_ID, newState);
509                         break;
510                     }
511                     case "Setenergy": {
512                         int state = entry.getValue().getAsInt();
513                         State newState = new QuantityType<Energy>(state / 10.0, Units.WATT_HOUR);
514                         updateState(CHANNEL_SETENERGY, newState);
515                         break;
516                     }
517                 }
518             }
519         } catch (JsonParseException e) {
520             logger.debug("Invalid JSON data will be ignored: '{}'", response);
521         }
522     }
523
524     @Override
525     public void handleCommand(ChannelUID channelUID, Command command) {
526         if ((command instanceof RefreshType)) {
527             // let's assume we do frequent enough polling and ignore the REFRESH request here
528             // in order to prevent too many channel state updates
529         } else {
530             switch (channelUID.getId()) {
531                 case CHANNEL_MAX_PRESET_CURRENT: {
532                     if (command instanceof QuantityType<?> quantityCommand) {
533                         QuantityType<?> value = Objects.requireNonNull(quantityCommand.toUnit("mA"));
534
535                         transceiver.send("curr " + Math.min(Math.max(6000, value.intValue()), maxSystemCurrent), this);
536                     }
537                     break;
538                 }
539                 case CHANNEL_MAX_PRESET_CURRENT_RANGE: {
540                     if (command instanceof OnOffType || command instanceof IncreaseDecreaseType
541                             || command instanceof QuantityType<?>) {
542                         long newValue = 6000;
543                         if (command == IncreaseDecreaseType.INCREASE) {
544                             newValue = Math.min(Math.max(6000, maxPresetCurrent + 1), maxSystemCurrent);
545                         } else if (command == IncreaseDecreaseType.DECREASE) {
546                             newValue = Math.min(Math.max(6000, maxPresetCurrent - 1), maxSystemCurrent);
547                         } else if (command == OnOffType.ON) {
548                             newValue = maxSystemCurrent;
549                         } else if (command == OnOffType.OFF) {
550                             newValue = 6000;
551                         } else if (command instanceof QuantityType<?> quantityCommand) {
552                             QuantityType<?> value = Objects.requireNonNull(quantityCommand.toUnit("%"));
553                             newValue = Math.round(6000 + (maxSystemCurrent - 6000) * value.doubleValue() / 100.0);
554                         } else {
555                             return;
556                         }
557                         transceiver.send("curr " + newValue, this);
558                     }
559                     break;
560                 }
561                 case CHANNEL_ENABLED: {
562                     if (command instanceof OnOffType) {
563                         if (command == OnOffType.ON) {
564                             transceiver.send("ena 1", this);
565                         } else if (command == OnOffType.OFF) {
566                             transceiver.send("ena 0", this);
567                         } else {
568                             return;
569                         }
570                     }
571                     break;
572                 }
573                 case CHANNEL_OUTPUT: {
574                     if (command instanceof OnOffType) {
575                         if (command == OnOffType.ON) {
576                             transceiver.send("output 1", this);
577                         } else if (command == OnOffType.OFF) {
578                             transceiver.send("output 0", this);
579                         } else {
580                             return;
581                         }
582                     }
583                     break;
584                 }
585                 case CHANNEL_DISPLAY: {
586                     if (command instanceof StringType) {
587                         if (type == KebaType.P30 && (series == KebaSeries.C || series == KebaSeries.X)) {
588                             String cmd = command.toString();
589                             int maxLength = (cmd.length() < 23) ? cmd.length() : 23;
590                             transceiver.send("display 0 0 0 0 " + cmd.substring(0, maxLength), this);
591                         } else {
592                             logger.warn("'Display' is not supported on a KEBA KeContact {}:{}", type, series);
593                         }
594                     }
595                     break;
596                 }
597                 case CHANNEL_SETENERGY: {
598                     if (command instanceof QuantityType<?> quantityCommand) {
599                         QuantityType<?> value = Objects.requireNonNull(quantityCommand.toUnit(Units.WATT_HOUR));
600                         transceiver.send(
601                                 "setenergy " + Math.min(Math.max(0, Math.round(value.doubleValue() * 10.0)), 999999999),
602                                 this);
603                     }
604                     break;
605                 }
606                 case CHANNEL_AUTHENTICATE: {
607                     if (command instanceof StringType) {
608                         String cmd = command.toString();
609                         // cmd must contain ID + CLASS (works only if the RFID TAG is in the whitelist of the Keba
610                         // station)
611                         transceiver.send("start " + cmd, this);
612                     }
613                     break;
614                 }
615             }
616         }
617     }
618 }