]> git.basschouten.com Git - openhab-addons.git/blob
c2b84ad9e6250a5a93227d9b569a95c8806bee83
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.freebox.internal.api;
14
15 import java.io.ByteArrayInputStream;
16 import java.io.IOException;
17 import java.io.InputStream;
18 import java.io.UnsupportedEncodingException;
19 import java.net.URLEncoder;
20 import java.nio.charset.StandardCharsets;
21 import java.security.InvalidKeyException;
22 import java.security.NoSuchAlgorithmException;
23 import java.util.ArrayList;
24 import java.util.List;
25 import java.util.Properties;
26 import java.util.concurrent.TimeUnit;
27
28 import javax.crypto.Mac;
29 import javax.crypto.spec.SecretKeySpec;
30
31 import org.openhab.binding.freebox.internal.api.model.FreeboxAirMediaConfig;
32 import org.openhab.binding.freebox.internal.api.model.FreeboxAirMediaConfigResponse;
33 import org.openhab.binding.freebox.internal.api.model.FreeboxAirMediaReceiver;
34 import org.openhab.binding.freebox.internal.api.model.FreeboxAirMediaReceiverRequest;
35 import org.openhab.binding.freebox.internal.api.model.FreeboxAirMediaReceiversResponse;
36 import org.openhab.binding.freebox.internal.api.model.FreeboxAuthorizationStatus;
37 import org.openhab.binding.freebox.internal.api.model.FreeboxAuthorizationStatusResponse;
38 import org.openhab.binding.freebox.internal.api.model.FreeboxAuthorizeRequest;
39 import org.openhab.binding.freebox.internal.api.model.FreeboxAuthorizeResponse;
40 import org.openhab.binding.freebox.internal.api.model.FreeboxAuthorizeResult;
41 import org.openhab.binding.freebox.internal.api.model.FreeboxCallEntry;
42 import org.openhab.binding.freebox.internal.api.model.FreeboxCallEntryResponse;
43 import org.openhab.binding.freebox.internal.api.model.FreeboxConnectionStatus;
44 import org.openhab.binding.freebox.internal.api.model.FreeboxConnectionStatusResponse;
45 import org.openhab.binding.freebox.internal.api.model.FreeboxDiscoveryResponse;
46 import org.openhab.binding.freebox.internal.api.model.FreeboxEmptyResponse;
47 import org.openhab.binding.freebox.internal.api.model.FreeboxFtpConfig;
48 import org.openhab.binding.freebox.internal.api.model.FreeboxFtpConfigResponse;
49 import org.openhab.binding.freebox.internal.api.model.FreeboxFtthStatusResponse;
50 import org.openhab.binding.freebox.internal.api.model.FreeboxLanConfigResponse;
51 import org.openhab.binding.freebox.internal.api.model.FreeboxLanHost;
52 import org.openhab.binding.freebox.internal.api.model.FreeboxLanHostsResponse;
53 import org.openhab.binding.freebox.internal.api.model.FreeboxLanInterface;
54 import org.openhab.binding.freebox.internal.api.model.FreeboxLanInterfacesResponse;
55 import org.openhab.binding.freebox.internal.api.model.FreeboxLcdConfig;
56 import org.openhab.binding.freebox.internal.api.model.FreeboxLcdConfigResponse;
57 import org.openhab.binding.freebox.internal.api.model.FreeboxLoginResponse;
58 import org.openhab.binding.freebox.internal.api.model.FreeboxOpenSessionRequest;
59 import org.openhab.binding.freebox.internal.api.model.FreeboxOpenSessionResponse;
60 import org.openhab.binding.freebox.internal.api.model.FreeboxPhoneStatus;
61 import org.openhab.binding.freebox.internal.api.model.FreeboxPhoneStatusResponse;
62 import org.openhab.binding.freebox.internal.api.model.FreeboxResponse;
63 import org.openhab.binding.freebox.internal.api.model.FreeboxSambaConfig;
64 import org.openhab.binding.freebox.internal.api.model.FreeboxSambaConfigResponse;
65 import org.openhab.binding.freebox.internal.api.model.FreeboxSystemConfig;
66 import org.openhab.binding.freebox.internal.api.model.FreeboxSystemConfigResponse;
67 import org.openhab.binding.freebox.internal.api.model.FreeboxUPnPAVConfig;
68 import org.openhab.binding.freebox.internal.api.model.FreeboxUPnPAVConfigResponse;
69 import org.openhab.binding.freebox.internal.api.model.FreeboxWifiGlobalConfig;
70 import org.openhab.binding.freebox.internal.api.model.FreeboxWifiGlobalConfigResponse;
71 import org.openhab.binding.freebox.internal.api.model.FreeboxXdslStatusResponse;
72 import org.openhab.core.io.net.http.HttpUtil;
73 import org.openhab.core.util.HexUtils;
74 import org.slf4j.Logger;
75 import org.slf4j.LoggerFactory;
76
77 import com.google.gson.FieldNamingPolicy;
78 import com.google.gson.Gson;
79 import com.google.gson.GsonBuilder;
80 import com.google.gson.JsonSyntaxException;
81
82 /**
83  * The {@link FreeboxApiManager} is responsible for the communication with the Freebox.
84  * It implements the different HTTP API calls provided by the Freebox
85  *
86  * @author Laurent Garnier - Initial contribution
87  */
88 public class FreeboxApiManager {
89
90     private final Logger logger = LoggerFactory.getLogger(FreeboxApiManager.class);
91
92     private static final int HTTP_CALL_DEFAULT_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(10);
93     private static final String AUTH_HEADER = "X-Fbx-App-Auth";
94     private static final String HTTP_CALL_CONTENT_TYPE = "application/json; charset=utf-8";
95
96     private String appId;
97     private String appName;
98     private String appVersion;
99     private String deviceName;
100     private String baseAddress;
101     private String appToken;
102     private String sessionToken;
103     private Gson gson;
104
105     public FreeboxApiManager(String appId, String appName, String appVersion, String deviceName) {
106         this.appId = appId;
107         this.appName = appName;
108         this.appVersion = appVersion;
109         this.deviceName = deviceName;
110         this.gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
111     }
112
113     public FreeboxDiscoveryResponse checkApi(String fqdn, boolean secureHttp) {
114         String url = String.format("%s://%s/api_version", secureHttp ? "https" : "http", fqdn);
115         try {
116             String jsonResponse = HttpUtil.executeUrl("GET", url, HTTP_CALL_DEFAULT_TIMEOUT_MS);
117             return gson.fromJson(jsonResponse, FreeboxDiscoveryResponse.class);
118         } catch (IOException | JsonSyntaxException e) {
119             logger.debug("checkApi with {} failed: {}", url, e.getMessage());
120             return null;
121         }
122     }
123
124     public boolean authorize(boolean useHttps, String fqdn, String apiBaseUrl, String apiVersion, String appToken)
125             throws InterruptedException {
126         String[] versionSplit = apiVersion.split("\\.");
127         String majorVersion = "5";
128         if (versionSplit.length > 0) {
129             majorVersion = versionSplit[0];
130         }
131         this.baseAddress = (useHttps ? "https://" : "http://") + fqdn + apiBaseUrl + "v" + majorVersion + "/";
132
133         boolean granted = false;
134         try {
135             String token = appToken;
136             if (token == null || token.isEmpty()) {
137                 FreeboxAuthorizeRequest request = new FreeboxAuthorizeRequest(appId, appName, appVersion, deviceName);
138                 FreeboxAuthorizeResult response = executePostUrl("login/authorize/", gson.toJson(request),
139                         FreeboxAuthorizeResponse.class, false, false, true);
140                 token = response.getAppToken();
141                 int trackId = response.getTrackId();
142                 FreeboxAuthorizationStatus result;
143                 do {
144                     Thread.sleep(2000);
145                     result = executeGetUrl("login/authorize/" + trackId, FreeboxAuthorizationStatusResponse.class,
146                             false);
147                 } while (result.isStatusPending());
148                 granted = result.isStatusGranted();
149             } else {
150                 granted = true;
151             }
152             if (!granted) {
153                 return false;
154             }
155
156             this.appToken = token;
157             openSession();
158             return true;
159         } catch (FreeboxException e) {
160             logger.debug("Error while opening a session", e);
161             return false;
162         }
163     }
164
165     private synchronized void openSession() throws FreeboxException {
166         if (appToken == null) {
167             throw new FreeboxException("No app token to open a new session");
168         }
169         sessionToken = null;
170         String challenge = executeGetUrl("login/", FreeboxLoginResponse.class, false).getChallenge();
171         FreeboxOpenSessionRequest request = new FreeboxOpenSessionRequest(appId, hmacSha1(appToken, challenge));
172         sessionToken = executePostUrl("login/session/", gson.toJson(request), FreeboxOpenSessionResponse.class, false,
173                 false, true).getSessionToken();
174     }
175
176     public synchronized void closeSession() {
177         if (sessionToken != null) {
178             try {
179                 executePostUrl("login/logout/", null, FreeboxEmptyResponse.class, false);
180             } catch (FreeboxException e) {
181             }
182             sessionToken = null;
183         }
184     }
185
186     public String getAppToken() {
187         return appToken;
188     }
189
190     public synchronized String getSessionToken() {
191         return sessionToken;
192     }
193
194     public FreeboxConnectionStatus getConnectionStatus() throws FreeboxException {
195         return executeGetUrl("connection/", FreeboxConnectionStatusResponse.class);
196     }
197
198     public String getxDslStatus() throws FreeboxException {
199         return executeGetUrl("connection/xdsl/", FreeboxXdslStatusResponse.class).getStatus();
200     }
201
202     public boolean getFtthPresent() throws FreeboxException {
203         return executeGetUrl("connection/ftth/", FreeboxFtthStatusResponse.class).getSfpPresent();
204     }
205
206     public boolean isWifiEnabled() throws FreeboxException {
207         return executeGetUrl("wifi/config/", FreeboxWifiGlobalConfigResponse.class).isEnabled();
208     }
209
210     public boolean enableWifi(boolean enable) throws FreeboxException {
211         FreeboxWifiGlobalConfig config = new FreeboxWifiGlobalConfig();
212         config.setEnabled(enable);
213         return executePutUrl("wifi/config/", gson.toJson(config), FreeboxWifiGlobalConfigResponse.class).isEnabled();
214     }
215
216     public boolean isFtpEnabled() throws FreeboxException {
217         return executeGetUrl("ftp/config/", FreeboxFtpConfigResponse.class).isEnabled();
218     }
219
220     public boolean enableFtp(boolean enable) throws FreeboxException {
221         FreeboxFtpConfig config = new FreeboxFtpConfig();
222         config.setEnabled(enable);
223         return executePutUrl("ftp/config/", gson.toJson(config), FreeboxFtpConfigResponse.class).isEnabled();
224     }
225
226     public boolean isAirMediaEnabled() throws FreeboxException {
227         return executeGetUrl("airmedia/config/", FreeboxAirMediaConfigResponse.class).isEnabled();
228     }
229
230     public boolean enableAirMedia(boolean enable) throws FreeboxException {
231         FreeboxAirMediaConfig config = new FreeboxAirMediaConfig();
232         config.setEnabled(enable);
233         return executePutUrl("airmedia/config/", gson.toJson(config), FreeboxAirMediaConfigResponse.class).isEnabled();
234     }
235
236     public boolean isUPnPAVEnabled() throws FreeboxException {
237         return executeGetUrl("upnpav/config/", FreeboxUPnPAVConfigResponse.class).isEnabled();
238     }
239
240     public boolean enableUPnPAV(boolean enable) throws FreeboxException {
241         FreeboxUPnPAVConfig config = new FreeboxUPnPAVConfig();
242         config.setEnabled(enable);
243         return executePutUrl("upnpav/config/", gson.toJson(config), FreeboxUPnPAVConfigResponse.class).isEnabled();
244     }
245
246     public FreeboxSambaConfig getSambaConfig() throws FreeboxException {
247         return executeGetUrl("netshare/samba/", FreeboxSambaConfigResponse.class);
248     }
249
250     public boolean enableSambaFileShare(boolean enable) throws FreeboxException {
251         FreeboxSambaConfig config = new FreeboxSambaConfig();
252         config.setFileShareEnabled(enable);
253         return executePutUrl("netshare/samba/", gson.toJson(config), FreeboxSambaConfigResponse.class)
254                 .isFileShareEnabled();
255     }
256
257     public boolean enableSambaPrintShare(boolean enable) throws FreeboxException {
258         FreeboxSambaConfig config = new FreeboxSambaConfig();
259         config.setPrintShareEnabled(enable);
260         return executePutUrl("netshare/samba/", gson.toJson(config), FreeboxSambaConfigResponse.class)
261                 .isPrintShareEnabled();
262     }
263
264     public FreeboxLcdConfig getLcdConfig() throws FreeboxException {
265         return executeGetUrl("lcd/config/", FreeboxLcdConfigResponse.class);
266     }
267
268     public int setLcdBrightness(int brightness) throws FreeboxException {
269         FreeboxLcdConfig config = getLcdConfig();
270         int newValue = Math.min(100, brightness);
271         newValue = Math.max(newValue, 0);
272         config.setBrightness(newValue);
273         return executePutUrl("lcd/config/", gson.toJson(config), FreeboxLcdConfigResponse.class).getBrightness();
274     }
275
276     public int increaseLcdBrightness() throws FreeboxException {
277         FreeboxLcdConfig config = getLcdConfig();
278         config.setBrightness(Math.min(100, config.getBrightness() + 1));
279         return executePutUrl("lcd/config/", gson.toJson(config), FreeboxLcdConfigResponse.class).getBrightness();
280     }
281
282     public int decreaseLcdBrightness() throws FreeboxException {
283         FreeboxLcdConfig config = getLcdConfig();
284         config.setBrightness(Math.max(0, config.getBrightness() - 1));
285         return executePutUrl("lcd/config/", gson.toJson(config), FreeboxLcdConfigResponse.class).getBrightness();
286     }
287
288     public FreeboxLcdConfig setLcdOrientation(int orientation) throws FreeboxException {
289         FreeboxLcdConfig config = getLcdConfig();
290         int newValue = Math.min(360, orientation);
291         newValue = Math.max(newValue, 0);
292         config.setOrientation(newValue);
293         config.setOrientationForced(true);
294         return executePutUrl("lcd/config/", gson.toJson(config), FreeboxLcdConfigResponse.class);
295     }
296
297     public boolean setLcdOrientationForced(boolean forced) throws FreeboxException {
298         FreeboxLcdConfig config = getLcdConfig();
299         config.setOrientationForced(forced);
300         return executePutUrl("lcd/config/", gson.toJson(config), FreeboxLcdConfigResponse.class).isOrientationForced();
301     }
302
303     public FreeboxSystemConfig getSystemConfig() throws FreeboxException {
304         return executeGetUrl("system/", FreeboxSystemConfigResponse.class);
305     }
306
307     public boolean isInLanBridgeMode() throws FreeboxException {
308         return executeGetUrl("lan/config/", FreeboxLanConfigResponse.class).isInBridgeMode();
309     }
310
311     public List<FreeboxLanHost> getLanHosts() throws FreeboxException {
312         List<FreeboxLanHost> hosts = new ArrayList<>();
313         List<FreeboxLanInterface> interfaces = executeGetUrl("lan/browser/interfaces/",
314                 FreeboxLanInterfacesResponse.class);
315         if (interfaces != null) {
316             for (FreeboxLanInterface lanInterface : interfaces) {
317                 if (lanInterface.getHostCount() > 0) {
318                     List<FreeboxLanHost> lanHosts = getLanHostsFromInterface(lanInterface.getName());
319                     if (lanHosts != null) {
320                         hosts.addAll(lanHosts);
321                     }
322                 }
323             }
324         }
325         return hosts;
326     }
327
328     private List<FreeboxLanHost> getLanHostsFromInterface(String lanInterface) throws FreeboxException {
329         return executeGetUrl("lan/browser/" + encodeUrl(lanInterface) + "/", FreeboxLanHostsResponse.class);
330     }
331
332     public FreeboxPhoneStatus getPhoneStatus() throws FreeboxException {
333         // This API is undocumented but working
334         // It is extracted from the freeboxos-java library
335         // https://github.com/MatMaul/freeboxos-java/blob/master/src/org/matmaul/freeboxos/phone/PhoneManager.java#L17
336         return executeGetUrl("phone/?_dc=1415032391207", FreeboxPhoneStatusResponse.class).get(0);
337     }
338
339     public List<FreeboxCallEntry> getCallEntries() throws FreeboxException {
340         return executeGetUrl("call/log/", FreeboxCallEntryResponse.class);
341     }
342
343     public List<FreeboxAirMediaReceiver> getAirMediaReceivers() throws FreeboxException {
344         return executeGetUrl("airmedia/receivers/", FreeboxAirMediaReceiversResponse.class, true, true, false);
345     }
346
347     public void playMedia(String url, String airPlayName, String airPlayPassword) throws FreeboxException {
348         FreeboxAirMediaReceiverRequest request = new FreeboxAirMediaReceiverRequest();
349         request.setStartAction();
350         request.setVideoMediaType();
351         if (airPlayPassword != null && !airPlayPassword.isEmpty()) {
352             request.setPassword(airPlayPassword);
353         }
354         request.setMedia(url);
355         executePostUrl("airmedia/receivers/" + encodeUrl(airPlayName) + "/", gson.toJson(request),
356                 FreeboxEmptyResponse.class, true, false, true);
357     }
358
359     public void stopMedia(String airPlayName, String airPlayPassword) throws FreeboxException {
360         FreeboxAirMediaReceiverRequest request = new FreeboxAirMediaReceiverRequest();
361         request.setStopAction();
362         request.setVideoMediaType();
363         if (airPlayPassword != null && !airPlayPassword.isEmpty()) {
364             request.setPassword(airPlayPassword);
365         }
366         executePostUrl("airmedia/receivers/" + encodeUrl(airPlayName) + "/", gson.toJson(request),
367                 FreeboxEmptyResponse.class, true, false, true);
368     }
369
370     public void reboot() throws FreeboxException {
371         executePostUrl("system/reboot/", null, FreeboxEmptyResponse.class);
372     }
373
374     private <T extends FreeboxResponse<F>, F> F executeGetUrl(String relativeUrl, Class<T> responseClass)
375             throws FreeboxException {
376         return executeUrl("GET", relativeUrl, null, responseClass, true, false, false);
377     }
378
379     private <T extends FreeboxResponse<F>, F> F executeGetUrl(String relativeUrl, Class<T> responseClass,
380             boolean retryAuth) throws FreeboxException {
381         return executeUrl("GET", relativeUrl, null, responseClass, retryAuth, false, false);
382     }
383
384     private <T extends FreeboxResponse<F>, F> F executeGetUrl(String relativeUrl, Class<T> responseClass,
385             boolean retryAuth, boolean patchTableReponse, boolean doNotLogData) throws FreeboxException {
386         return executeUrl("GET", relativeUrl, null, responseClass, retryAuth, patchTableReponse, doNotLogData);
387     }
388
389     private <T extends FreeboxResponse<F>, F> F executePostUrl(String relativeUrl, String requestContent,
390             Class<T> responseClass) throws FreeboxException {
391         return executeUrl("POST", relativeUrl, requestContent, responseClass, true, false, false);
392     }
393
394     private <T extends FreeboxResponse<F>, F> F executePostUrl(String relativeUrl, String requestContent,
395             Class<T> responseClass, boolean retryAuth) throws FreeboxException {
396         return executeUrl("POST", relativeUrl, requestContent, responseClass, retryAuth, false, false);
397     }
398
399     private <T extends FreeboxResponse<F>, F> F executePostUrl(String relativeUrl, String requestContent,
400             Class<T> responseClass, boolean retryAuth, boolean patchTableReponse, boolean doNotLogData)
401             throws FreeboxException {
402         return executeUrl("POST", relativeUrl, requestContent, responseClass, retryAuth, patchTableReponse,
403                 doNotLogData);
404     }
405
406     private <T extends FreeboxResponse<F>, F> F executePutUrl(String relativeUrl, String requestContent,
407             Class<T> responseClass) throws FreeboxException {
408         return executeUrl("PUT", relativeUrl, requestContent, responseClass, true, false, false);
409     }
410
411     private <T extends FreeboxResponse<F>, F> F executeUrl(String httpMethod, String relativeUrl, String requestContent,
412             Class<T> responseClass, boolean retryAuth, boolean patchTableReponse, boolean doNotLogData)
413             throws FreeboxException {
414         try {
415             Properties headers = null;
416             String token = getSessionToken();
417             if (token != null) {
418                 headers = new Properties();
419                 headers.setProperty(AUTH_HEADER, token);
420             }
421             InputStream stream = null;
422             String contentType = null;
423             if (requestContent != null) {
424                 stream = new ByteArrayInputStream(requestContent.getBytes(StandardCharsets.UTF_8));
425                 contentType = HTTP_CALL_CONTENT_TYPE;
426             }
427             logger.debug("executeUrl {} {} requestContent {}", httpMethod, relativeUrl,
428                     doNotLogData ? "***" : requestContent);
429             String jsonResponse = HttpUtil.executeUrl(httpMethod, baseAddress + relativeUrl, headers, stream,
430                     contentType, HTTP_CALL_DEFAULT_TIMEOUT_MS);
431             if (stream != null) {
432                 stream.close();
433                 stream = null;
434             }
435
436             if (patchTableReponse) {
437                 // Replace empty result by an empty table result
438                 jsonResponse = jsonResponse.replace("\"result\":{}", "\"result\":[]");
439             }
440
441             return evaluateJsonReesponse(jsonResponse, responseClass, doNotLogData);
442         } catch (FreeboxException e) {
443             if (retryAuth && e.isAuthRequired()) {
444                 logger.debug("Authentication required: open a new session and retry the request");
445                 openSession();
446                 return executeUrl(httpMethod, relativeUrl, requestContent, responseClass, false, patchTableReponse,
447                         doNotLogData);
448             }
449             throw e;
450         } catch (IOException e) {
451             throw new FreeboxException(httpMethod + " request " + relativeUrl + ": execution failed: " + e.getMessage(),
452                     e);
453         } catch (JsonSyntaxException e) {
454             throw new FreeboxException(
455                     httpMethod + " request " + relativeUrl + ": response parsing failed: " + e.getMessage(), e);
456         }
457     }
458
459     private <T extends FreeboxResponse<F>, F> F evaluateJsonReesponse(String jsonResponse, Class<T> responseClass,
460             boolean doNotLogData) throws JsonSyntaxException, FreeboxException {
461         logger.debug("evaluateJsonReesponse Json {}", doNotLogData ? "***" : jsonResponse);
462         // First check only if the result is successful
463         FreeboxResponse<Object> partialResponse = gson.fromJson(jsonResponse, FreeboxEmptyResponse.class);
464         partialResponse.evaluate();
465         // Parse the full response in case of success
466         T fullResponse = gson.fromJson(jsonResponse, responseClass);
467         fullResponse.evaluate();
468         F result = fullResponse.getResult();
469         return result;
470     }
471
472     private String encodeUrl(String url) throws FreeboxException {
473         try {
474             return URLEncoder.encode(url, StandardCharsets.UTF_8.name());
475         } catch (UnsupportedEncodingException e) {
476             throw new FreeboxException("Encoding the URL \"" + url + "\" in UTF-8 failed", e);
477         }
478     }
479
480     public static String hmacSha1(String key, String value) throws FreeboxException {
481         try {
482             // Get an hmac_sha1 key from the raw key bytes
483             byte[] keyBytes = key.getBytes();
484             SecretKeySpec signingKey = new SecretKeySpec(keyBytes, "HmacSHA1");
485
486             // Get an hmac_sha1 Mac instance and initialize with the signing key
487             Mac mac = Mac.getInstance("HmacSHA1");
488             mac.init(signingKey);
489
490             // Compute the hmac on input data bytes
491             byte[] rawHmac = mac.doFinal(value.getBytes());
492
493             // Convert raw bytes to a String
494             return HexUtils.bytesToHex(rawHmac).toLowerCase();
495         } catch (IllegalArgumentException | NoSuchAlgorithmException | InvalidKeyException | IllegalStateException e) {
496             throw new FreeboxException("Computing the hmac-sha1 of the challenge and the app token failed", e);
497         }
498     }
499 }