]> git.basschouten.com Git - openhab-addons.git/blob
b34c8c08e364441476f758a1484ecf16d0580a79
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.energidataservice.internal.handler;
14
15 import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
16 import static org.openhab.core.types.TimeSeries.Policy.REPLACE;
17
18 import java.math.BigDecimal;
19 import java.time.Duration;
20 import java.time.Instant;
21 import java.time.LocalDateTime;
22 import java.time.LocalTime;
23 import java.time.format.DateTimeFormatter;
24 import java.time.temporal.ChronoUnit;
25 import java.util.Arrays;
26 import java.util.Collection;
27 import java.util.Currency;
28 import java.util.HashMap;
29 import java.util.List;
30 import java.util.Map;
31 import java.util.Map.Entry;
32 import java.util.Set;
33 import java.util.concurrent.ScheduledFuture;
34 import java.util.concurrent.TimeUnit;
35
36 import javax.measure.Unit;
37
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.eclipse.jetty.client.HttpClient;
41 import org.eclipse.jetty.http.HttpStatus;
42 import org.openhab.binding.energidataservice.internal.ApiController;
43 import org.openhab.binding.energidataservice.internal.CacheManager;
44 import org.openhab.binding.energidataservice.internal.DatahubTariff;
45 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
46 import org.openhab.binding.energidataservice.internal.api.ChargeType;
47 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
48 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
49 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
50 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
51 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
52 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
53 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
54 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
55 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
56 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
57 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
58 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
59 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
60 import org.openhab.core.i18n.TimeZoneProvider;
61 import org.openhab.core.library.types.DecimalType;
62 import org.openhab.core.library.types.QuantityType;
63 import org.openhab.core.library.types.StringType;
64 import org.openhab.core.library.unit.CurrencyUnits;
65 import org.openhab.core.thing.Channel;
66 import org.openhab.core.thing.ChannelUID;
67 import org.openhab.core.thing.Thing;
68 import org.openhab.core.thing.ThingStatus;
69 import org.openhab.core.thing.ThingStatusDetail;
70 import org.openhab.core.thing.binding.BaseThingHandler;
71 import org.openhab.core.thing.binding.ThingHandlerService;
72 import org.openhab.core.types.Command;
73 import org.openhab.core.types.RefreshType;
74 import org.openhab.core.types.State;
75 import org.openhab.core.types.TimeSeries;
76 import org.openhab.core.types.UnDefType;
77 import org.slf4j.Logger;
78 import org.slf4j.LoggerFactory;
79
80 import com.google.gson.Gson;
81
82 /**
83  * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
84  * sent to one of the channels.
85  *
86  * @author Jacob Laursen - Initial contribution
87  */
88 @NonNullByDefault
89 public class EnergiDataServiceHandler extends BaseThingHandler {
90
91     private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
92     private final TimeZoneProvider timeZoneProvider;
93     private final ApiController apiController;
94     private final CacheManager cacheManager;
95     private final Gson gson = new Gson();
96
97     private EnergiDataServiceConfiguration config;
98     private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
99     private @Nullable ScheduledFuture<?> refreshFuture;
100     private @Nullable ScheduledFuture<?> priceUpdateFuture;
101
102     private record Price(String hourStart, BigDecimal spotPrice, String spotPriceCurrency,
103             @Nullable BigDecimal gridTariff, @Nullable BigDecimal systemTariff,
104             @Nullable BigDecimal transmissionGridTariff, @Nullable BigDecimal electricityTax,
105             @Nullable BigDecimal reducedElectricityTax) {
106     }
107
108     public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
109         super(thing);
110         this.timeZoneProvider = timeZoneProvider;
111         this.apiController = new ApiController(httpClient, timeZoneProvider);
112         this.cacheManager = new CacheManager();
113
114         // Default configuration
115         this.config = new EnergiDataServiceConfiguration();
116     }
117
118     @Override
119     public void handleCommand(ChannelUID channelUID, Command command) {
120         if (!(command instanceof RefreshType)) {
121             return;
122         }
123
124         if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
125             refreshElectricityPrices();
126         }
127     }
128
129     @Override
130     public void initialize() {
131         config = getConfigAs(EnergiDataServiceConfiguration.class);
132
133         if (config.priceArea.isBlank()) {
134             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
135                     "@text/offline.conf-error.no-price-area");
136             return;
137         }
138         GlobalLocationNumber gln = config.getGridCompanyGLN();
139         if (!gln.isEmpty() && !gln.isValid()) {
140             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
141                     "@text/offline.conf-error.invalid-grid-company-gln");
142             return;
143         }
144         gln = config.getEnerginetGLN();
145         if (!gln.isEmpty() && !gln.isValid()) {
146             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
147                     "@text/offline.conf-error.invalid-energinet-gln");
148             return;
149         }
150
151         updateStatus(ThingStatus.UNKNOWN);
152
153         refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
154     }
155
156     @Override
157     public void dispose() {
158         ScheduledFuture<?> refreshFuture = this.refreshFuture;
159         if (refreshFuture != null) {
160             refreshFuture.cancel(true);
161             this.refreshFuture = null;
162         }
163         ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
164         if (priceUpdateFuture != null) {
165             priceUpdateFuture.cancel(true);
166             this.priceUpdateFuture = null;
167         }
168
169         cacheManager.clear();
170     }
171
172     @Override
173     public Collection<Class<? extends ThingHandlerService>> getServices() {
174         return Set.of(EnergiDataServiceActions.class);
175     }
176
177     private void refreshElectricityPrices() {
178         RetryStrategy retryPolicy;
179         try {
180             if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
181                 downloadSpotPrices();
182             }
183
184             for (DatahubTariff datahubTariff : DatahubTariff.values()) {
185                 if (isLinked(datahubTariff.getChannelId()) || isLinked(CHANNEL_HOURLY_PRICES)) {
186                     downloadTariffs(datahubTariff);
187                 }
188             }
189
190             updateStatus(ThingStatus.ONLINE);
191             updatePrices();
192             updateTimeSeries();
193
194             if (isLinked(CHANNEL_SPOT_PRICE) || isLinked(CHANNEL_HOURLY_PRICES)) {
195                 if (cacheManager.getNumberOfFutureSpotPrices() < 13) {
196                     retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing(DAILY_REFRESH_TIME_CET,
197                             NORD_POOL_TIMEZONE);
198                 } else {
199                     retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
200                 }
201             } else {
202                 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
203             }
204         } catch (DataServiceException e) {
205             if (e.getHttpStatus() != 0) {
206                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
207                         HttpStatus.getCode(e.getHttpStatus()).getMessage());
208             } else {
209                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
210             }
211             if (e.getCause() != null) {
212                 logger.debug("Error retrieving prices", e);
213             }
214             retryPolicy = RetryPolicyFactory.fromThrowable(e);
215         } catch (InterruptedException e) {
216             logger.debug("Refresh job interrupted");
217             Thread.currentThread().interrupt();
218             return;
219         }
220
221         rescheduleRefreshJob(retryPolicy);
222     }
223
224     private void downloadSpotPrices() throws InterruptedException, DataServiceException {
225         if (cacheManager.areSpotPricesFullyCached()) {
226             logger.debug("Cached spot prices still valid, skipping download.");
227             return;
228         }
229         DateQueryParameter start;
230         if (cacheManager.areHistoricSpotPricesCached()) {
231             start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
232         } else {
233             start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
234                     Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
235         }
236         Map<String, String> properties = editProperties();
237         ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
238                 start, properties);
239         cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
240         updateProperties(properties);
241     }
242
243     private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
244         GlobalLocationNumber globalLocationNumber = switch (datahubTariff) {
245             case GRID_TARIFF -> config.getGridCompanyGLN();
246             default -> config.getEnerginetGLN();
247         };
248         if (globalLocationNumber.isEmpty()) {
249             return;
250         }
251         if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
252             logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
253             cacheManager.updateTariffs(datahubTariff);
254         } else {
255             DatahubTariffFilter filter = switch (datahubTariff) {
256                 case GRID_TARIFF -> getGridTariffFilter();
257                 case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
258                 case TRANSMISSION_GRID_TARIFF -> DatahubTariffFilterFactory.getTransmissionGridTariff();
259                 case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
260                 case REDUCED_ELECTRICITY_TAX -> DatahubTariffFilterFactory.getReducedElectricityTax();
261             };
262             cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
263         }
264     }
265
266     private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
267             DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
268         Map<String, String> properties = editProperties();
269         Collection<DatahubPricelistRecord> records = apiController.getDatahubPriceLists(globalLocationNumber,
270                 ChargeType.Tariff, filter, properties);
271         updateProperties(properties);
272
273         return records;
274     }
275
276     private DatahubTariffFilter getGridTariffFilter() {
277         Channel channel = getThing().getChannel(CHANNEL_GRID_TARIFF);
278         if (channel == null) {
279             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
280         }
281
282         DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
283                 .as(DatahubPriceConfiguration.class);
284
285         if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
286             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
287         }
288
289         DateQueryParameter start = datahubPriceConfiguration.getStart();
290         if (start == null) {
291             logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
292                     datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
293             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
294         }
295
296         Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
297         Set<String> notes = datahubPriceConfiguration.getNotes();
298         DatahubTariffFilter filter;
299         if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
300             // Completely override filter.
301             filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
302         } else {
303             // Only override start date in pre-configured filter.
304             filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN),
305                     start);
306         }
307
308         return new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getDateQueryParameter(),
309                 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
310     }
311
312     private void updatePrices() {
313         cacheManager.cleanup();
314
315         updateCurrentSpotPrice();
316         Arrays.stream(DatahubTariff.values())
317                 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
318         updateHourlyPrices();
319
320         reschedulePriceUpdateJob();
321     }
322
323     private void updateCurrentSpotPrice() {
324         if (!isLinked(CHANNEL_SPOT_PRICE)) {
325             return;
326         }
327         BigDecimal spotPrice = cacheManager.getSpotPrice();
328         updatePriceState(CHANNEL_SPOT_PRICE, spotPrice, config.getCurrency());
329     }
330
331     private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
332         if (!isLinked(channelId)) {
333             return;
334         }
335         updatePriceState(channelId, tariff, CURRENCY_DKK);
336     }
337
338     private void updatePriceState(String channelID, @Nullable BigDecimal price, Currency currency) {
339         updateState(channelID, price != null ? getEnergyPrice(price, currency) : UnDefType.UNDEF);
340     }
341
342     private State getEnergyPrice(BigDecimal price, Currency currency) {
343         String currencyCode = currency.getCurrencyCode();
344         Unit<?> unit = CurrencyUnits.getInstance().getUnit(currencyCode);
345         if (unit == null) {
346             logger.trace("Currency {} is unknown, falling back to DecimalType", currency.getCurrencyCode());
347             return new DecimalType(price);
348         }
349         try {
350             return new QuantityType<>(price + " " + currencyCode + "/kWh");
351         } catch (IllegalArgumentException e) {
352             logger.debug("Unable to create QuantityType, falling back to DecimalType", e);
353             return new DecimalType(price);
354         }
355     }
356
357     private void updateHourlyPrices() {
358         if (!isLinked(CHANNEL_HOURLY_PRICES)) {
359             return;
360         }
361         Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
362         Price[] targetPrices = new Price[spotPriceMap.size()];
363         List<Entry<Instant, BigDecimal>> sourcePrices = spotPriceMap.entrySet().stream()
364                 .sorted(Map.Entry.comparingByKey()).toList();
365
366         int i = 0;
367         for (Entry<Instant, BigDecimal> sourcePrice : sourcePrices) {
368             Instant hourStart = sourcePrice.getKey();
369             BigDecimal gridTariff = cacheManager.getTariff(DatahubTariff.GRID_TARIFF, hourStart);
370             BigDecimal systemTariff = cacheManager.getTariff(DatahubTariff.SYSTEM_TARIFF, hourStart);
371             BigDecimal transmissionGridTariff = cacheManager.getTariff(DatahubTariff.TRANSMISSION_GRID_TARIFF,
372                     hourStart);
373             BigDecimal electricityTax = cacheManager.getTariff(DatahubTariff.ELECTRICITY_TAX, hourStart);
374             BigDecimal reducedElectricityTax = cacheManager.getTariff(DatahubTariff.REDUCED_ELECTRICITY_TAX, hourStart);
375             targetPrices[i++] = new Price(hourStart.toString(), sourcePrice.getValue(), config.currencyCode, gridTariff,
376                     systemTariff, electricityTax, reducedElectricityTax, transmissionGridTariff);
377         }
378         updateState(CHANNEL_HOURLY_PRICES, new StringType(gson.toJson(targetPrices)));
379     }
380
381     private void updateTimeSeries() {
382         TimeSeries spotPriceTimeSeries = new TimeSeries(REPLACE);
383         Map<DatahubTariff, TimeSeries> datahubTimeSeriesMap = new HashMap<>();
384         Map<DatahubTariff, BigDecimal> datahubPreviousTariff = new HashMap<>();
385         for (DatahubTariff datahubTariff : DatahubTariff.values()) {
386             datahubTimeSeriesMap.put(datahubTariff, new TimeSeries(REPLACE));
387         }
388
389         Map<Instant, BigDecimal> spotPriceMap = cacheManager.getSpotPrices();
390         List<Entry<Instant, BigDecimal>> spotPrices = spotPriceMap.entrySet().stream()
391                 .sorted(Map.Entry.comparingByKey()).toList();
392         for (Entry<Instant, BigDecimal> spotPrice : spotPrices) {
393             Instant hourStart = spotPrice.getKey();
394             if (isLinked(CHANNEL_SPOT_PRICE)) {
395                 spotPriceTimeSeries.add(hourStart, getEnergyPrice(spotPrice.getValue(), config.getCurrency()));
396             }
397             for (Map.Entry<DatahubTariff, TimeSeries> entry : datahubTimeSeriesMap.entrySet()) {
398                 DatahubTariff datahubTariff = entry.getKey();
399                 String channelId = datahubTariff.getChannelId();
400                 if (!isLinked(channelId)) {
401                     continue;
402                 }
403                 BigDecimal tariff = cacheManager.getTariff(datahubTariff, hourStart);
404                 if (tariff != null) {
405                     BigDecimal previousTariff = datahubPreviousTariff.get(datahubTariff);
406                     if (previousTariff != null && tariff.equals(previousTariff)) {
407                         // Skip redundant states.
408                         continue;
409                     }
410                     TimeSeries timeSeries = entry.getValue();
411                     timeSeries.add(hourStart, getEnergyPrice(tariff, CURRENCY_DKK));
412                     datahubPreviousTariff.put(datahubTariff, tariff);
413                 }
414             }
415         }
416         if (spotPriceTimeSeries.size() > 0) {
417             sendTimeSeries(CHANNEL_SPOT_PRICE, spotPriceTimeSeries);
418         }
419         for (Map.Entry<DatahubTariff, TimeSeries> entry : datahubTimeSeriesMap.entrySet()) {
420             DatahubTariff datahubTariff = entry.getKey();
421             String channelId = datahubTariff.getChannelId();
422             if (!isLinked(channelId)) {
423                 continue;
424             }
425             TimeSeries timeSeries = entry.getValue();
426             if (timeSeries.size() > 0) {
427                 sendTimeSeries(channelId, timeSeries);
428             }
429         }
430     }
431
432     /**
433      * Get the configured {@link Currency} for spot prices.
434      * 
435      * @return Spot price currency
436      */
437     public Currency getCurrency() {
438         return config.getCurrency();
439     }
440
441     /**
442      * Get cached spot prices or try once to download them if not cached
443      * (usually if no items are linked).
444      *
445      * @return Map of future spot prices
446      */
447     public Map<Instant, BigDecimal> getSpotPrices() {
448         try {
449             downloadSpotPrices();
450         } catch (DataServiceException e) {
451             if (logger.isDebugEnabled()) {
452                 logger.warn("Error retrieving spot prices", e);
453             } else {
454                 logger.warn("Error retrieving spot prices: {}", e.getMessage());
455             }
456         } catch (InterruptedException e) {
457             Thread.currentThread().interrupt();
458         }
459
460         return cacheManager.getSpotPrices();
461     }
462
463     /**
464      * Return cached tariffs or try once to download them if not cached
465      * (usually if no items are linked).
466      *
467      * @return Map of future tariffs
468      */
469     public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
470         try {
471             downloadTariffs(datahubTariff);
472         } catch (DataServiceException e) {
473             if (logger.isDebugEnabled()) {
474                 logger.warn("Error retrieving tariffs", e);
475             } else {
476                 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
477             }
478         } catch (InterruptedException e) {
479             Thread.currentThread().interrupt();
480         }
481
482         return cacheManager.getTariffs(datahubTariff);
483     }
484
485     /**
486      * Return whether reduced electricity tax is set in configuration.
487      *
488      * @return true if reduced electricity tax applies
489      */
490     public boolean isReducedElectricityTax() {
491         return config.reducedElectricityTax;
492     }
493
494     private void reschedulePriceUpdateJob() {
495         ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
496         if (priceUpdateJob != null) {
497             // Do not interrupt ourselves.
498             priceUpdateJob.cancel(false);
499             this.priceUpdateFuture = null;
500         }
501
502         Instant now = Instant.now();
503         long millisUntilNextClockHour = Duration
504                 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
505         this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
506                 TimeUnit.MILLISECONDS);
507         logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
508     }
509
510     private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
511         // Preserve state of previous retry policy when configuration is the same.
512         if (!retryPolicy.equals(this.retryPolicy)) {
513             this.retryPolicy = retryPolicy;
514         }
515
516         ScheduledFuture<?> refreshJob = this.refreshFuture;
517
518         long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
519         Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
520         this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
521                 TimeUnit.SECONDS);
522         logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
523         DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
524         updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
525                 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
526
527         if (refreshJob != null) {
528             refreshJob.cancel(true);
529         }
530     }
531 }