/* ================================================================== * DefaultKioskDataService.java - 23/10/2016 6:31:26 AM * * Copyright 2007-2016 SolarNetwork.net Dev Team * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA * 02111-1307 USA * ================================================================== */ package net.solarnetwork.node.ocpp.kiosk.web; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import org.osgi.service.event.Event; import org.osgi.service.event.EventHandler; import org.quartz.JobBuilder; import org.quartz.JobDataMap; import org.quartz.JobDetail; import org.quartz.JobKey; import org.quartz.Scheduler; import org.quartz.SchedulerException; import org.quartz.SimpleScheduleBuilder; import org.quartz.SimpleTrigger; import org.quartz.TriggerBuilder; import org.quartz.TriggerKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.MessageSource; import org.springframework.core.task.TaskExecutor; import org.springframework.messaging.simp.SimpMessageSendingOperations; import net.solarnetwork.node.DatumDataSource; import net.solarnetwork.node.domain.ACEnergyDatum; import net.solarnetwork.node.ocpp.ChargeSession; import net.solarnetwork.node.ocpp.ChargeSessionManager; import net.solarnetwork.node.ocpp.ChargeSessionMeterReading; import net.solarnetwork.node.settings.SettingSpecifier; import net.solarnetwork.node.settings.SettingSpecifierProvider; import net.solarnetwork.node.settings.support.BasicGroupSettingSpecifier; import net.solarnetwork.node.settings.support.BasicTextFieldSettingSpecifier; import net.solarnetwork.node.settings.support.SettingsUtil; import net.solarnetwork.util.DynamicServiceUnavailableException; import net.solarnetwork.util.FilterableService; import net.solarnetwork.util.OptionalService; import net.solarnetwork.util.StringUtils; import ocpp.v15.cs.Measurand; import ocpp.v15.cs.ReadingContext; /** * Default implementation of {@link KioskDataService}. * * @author matt * @version 1.0 */ public class DefaultKioskDataService implements KioskDataService, EventHandler, SettingSpecifierProvider { /** * The name used to schedule the {@link KioskDataServiceRefreshJob} as. */ public static final String KIOSK_REFRESH_JOB_NAME = "OCPP_KioskRefresh"; /** * The job and trigger group used to schedule the * {@link KioskDataServiceRefreshJob} with. */ public static final String SCHEDULER_GROUP = "OCPP"; /** * The interval at which to refresh the kiosk data. */ public static final long REFRESH_JOB_INTERVAL = 2 * 1000L; // model data for kiosk private final Map<String, Object> kioskData; // a mapping of socket ID -> model data private final Map<String, Map<String, Object>> socketDataMap; // a cache of socket ID -> data source for meter data private final Map<String, DatumDataSource<ACEnergyDatum>> socketMeterDataSources; // last seen PV generation power, by source ID private final ConcurrentMap<String, AtomicInteger> pvPowerMap; // pv data private final Map<String, Object> pvDataMap; private List<SocketConfiguration> socketConfigurations; private Set<String> pvSourceIdSet; private Collection<DatumDataSource<ACEnergyDatum>> meterDataSources; private ChargeSessionManager chargeSessionManager; private OptionalService<SimpMessageSendingOperations> messageSendingOps; private Scheduler scheduler; private MessageSource messageSource; private TaskExecutor taskExecutor; private SimpleTrigger refreshKioskDataTrigger; private final AtomicBoolean sessionDataRefreshNeeded = new AtomicBoolean(true); private final Logger log = LoggerFactory.getLogger(getClass()); public DefaultKioskDataService() { super(); socketMeterDataSources = new HashMap<String, DatumDataSource<ACEnergyDatum>>(2); socketDataMap = new ConcurrentHashMap<String, Map<String, Object>>(2); socketConfigurations = new ArrayList<SocketConfiguration>(2); pvSourceIdSet = new LinkedHashSet<String>(1); pvPowerMap = new ConcurrentHashMap<String, AtomicInteger>(2); pvDataMap = new HashMap<String, Object>(2); pvDataMap.put("power", new AtomicInteger(0)); Map<String, Object> kData = new LinkedHashMap<String, Object>(8); kData.put("socketData", socketDataMap); kData.put("pvData", pvDataMap); kioskData = Collections.unmodifiableMap(kData); } @Override public void startup() { log.info("Starting up OCPP kiosk data service"); configureKioskRefreshJob(REFRESH_JOB_INTERVAL); } @Override public void shutdown() { configureKioskRefreshJob(0); } /** * Call to notify of any configuration changes, for example on any of the * configured {@link SocketConfiguration} instances. */ public void configurationChanged(Map<String, ?> properties) { refreshSessionDataForConfiguredSockets(); } private synchronized void refreshSessionDataForConfiguredSockets() { List<SocketConfiguration> confs = getSocketConfigurations(); if ( confs == null || confs.isEmpty() ) { return; } for ( SocketConfiguration conf : confs ) { final String socketId = conf.getSocketId(); if ( socketId == null ) { continue; } final String socketKey = conf.getKey(); if ( socketKey == null ) { continue; } try { populateSessionDataForSocket(socketId); } catch ( DynamicServiceUnavailableException e ) { // refresh again later (via refreshKioskData} in case the OCPP service was not yet available at startup sessionDataRefreshNeeded.set(true); return; } } sessionDataRefreshNeeded.set(false); } @Override public Map<String, Object> getKioskData() { return kioskData; } @Override public void handleEvent(final Event event) { // use the task executor if available, to avoid being blacklisted TaskExecutor executor = taskExecutor; if ( executor != null ) { executor.execute(new Runnable() { @Override public void run() { handleEventInternal(event); } }); } else { handleEventInternal(event); } } private void handleEventInternal(Event event) { final String topic = event.getTopic(); if ( topic.equals(ChargeSessionManager.EVENT_TOPIC_SESSION_STARTED) || topic.equals(ChargeSessionManager.EVENT_TOPIC_SESSION_ENDED) ) { handleSessionEvent(event); } else if ( topic.equals(DatumDataSource.EVENT_TOPIC_DATUM_CAPTURED) ) { handleDatumCapturedEvent(event); } } private String socketKeyForId(String socketId) { List<SocketConfiguration> confs = getSocketConfigurations(); if ( socketId == null || confs == null || confs.isEmpty() ) { return null; } for ( SocketConfiguration conf : confs ) { if ( socketId.equals(conf.getSocketId()) ) { return conf.getKey(); } } return null; } private void handleSessionEvent(Event event) { final boolean sessionStarted = (ChargeSessionManager.EVENT_TOPIC_SESSION_STARTED .equals(event.getTopic())); final String sessionId = (String) event .getProperty(ChargeSessionManager.EVENT_PROPERTY_SESSION_ID); final String socketId = (String) event .getProperty(ChargeSessionManager.EVENT_PROPERTY_SOCKET_ID); final String socketKey = socketKeyForId(socketId); if ( socketKey == null ) { return; } Map<String, Object> sessionData = socketDataMap.get(socketKey); if ( sessionStarted && sessionData == null ) { sessionData = new HashMap<String, Object>(8); sessionData.put("sessionId", sessionId); sessionData.put("socketId", socketId); Number n = (Number) event.getProperty(ChargeSessionManager.EVENT_PROPERTY_DATE); if ( n != null ) { sessionData.put("startDate", n); } sessionData.put("duration", new AtomicLong(0L)); n = (Number) event.getProperty(ChargeSessionManager.EVENT_PROPERTY_METER_READING_POWER); sessionData.put("power", new AtomicInteger(n != null ? n.intValue() : 0)); n = (Number) event.getProperty(ChargeSessionManager.EVENT_PROPERTY_METER_READING_ENERGY); sessionData.put("energyStart", n != null ? n : 0L); sessionData.put("energy", new AtomicInteger(0)); sessionData.put("endDate", new AtomicLong(0)); socketDataMap.put(socketKey, Collections.unmodifiableMap(sessionData)); } else if ( sessionData != null ) { updateSessionData(sessionData, (Number) event.getProperty(ChargeSessionManager.EVENT_PROPERTY_METER_READING_POWER), (Number) event.getProperty(ChargeSessionManager.EVENT_PROPERTY_METER_READING_ENERGY), (Number) event.getProperty(ChargeSessionManager.EVENT_PROPERTY_DATE)); } postMessage(MESSAGE_TOPIC_KIOSK_DATA, kioskData); if ( !sessionStarted ) { socketDataMap.remove(socketKey); } } private void handleDatumCapturedEvent(Event event) { final Object sourceIdObj = event.getProperty("sourceId"); if ( sourceIdObj == null ) { return; } String sourceId = sourceIdObj.toString(); if ( !pvSourceIdSet.contains(sourceId) ) { return; } Object powerObj = event.getProperty("watts"); if ( !(powerObj instanceof Number) ) { return; } Number power = (Number) powerObj; AtomicInteger currPower = pvPowerMap.get(sourceId); if ( currPower != null ) { currPower.set(power.intValue()); } else { currPower = pvPowerMap.putIfAbsent(sourceId, new AtomicInteger(power.intValue())); if ( currPower != null ) { currPower.set(power.intValue()); } } } private void postMessage(String topic, Object payload) { SimpMessageSendingOperations ops = (messageSendingOps != null ? messageSendingOps.service() : null); if ( ops == null ) { return; } ops.convertAndSend(topic, payload); } private void updateSessionData(Map<String, Object> sessionData, Number powerReading, Number energyReading, Number endDate) { // update power value AtomicInteger power = (AtomicInteger) sessionData.get("power"); if ( powerReading != null && power != null ) { power.set(powerReading.intValue()); } // update energy value Number energyStart = (Number) sessionData.get("energyStart"); AtomicInteger energy = (AtomicInteger) sessionData.get("energy"); if ( energyReading != null && energyStart != null && energy != null ) { int oldEnergy = energy.get(); int newEnergy = (int) (energyReading.longValue() - energyStart.longValue()); energy.compareAndSet(oldEnergy, newEnergy); } // update duration, end date Number startDate = (Number) sessionData.get("startDate"); long durationDate = System.currentTimeMillis(); AtomicLong end = (AtomicLong) sessionData.get("endDate"); if ( endDate != null && end != null ) { end.compareAndSet(0, endDate.longValue()); durationDate = end.get(); } AtomicLong duration = (AtomicLong) sessionData.get("duration"); if ( startDate != null && duration != null ) { duration.set(durationDate - startDate.longValue()); } } private Map<String, Object> sessionDataForSocket(String socketId) { final String socketKey = socketKeyForId(socketId); if ( socketKey == null ) { return null; } return socketDataMap.get(socketKey); } private void populateSessionDataForSocket(String socketId) { ChargeSession session = chargeSessionManager.activeChargeSession(socketId); if ( session == null ) { // no session info for this socket return; } // maybe the node has restarted mid-session; pretend we got a Start event Map<String, Object> eventData = new HashMap<String, Object>(8); eventData.put(ChargeSessionManager.EVENT_PROPERTY_DATE, session.getCreated().getTime()); eventData.put(ChargeSessionManager.EVENT_PROPERTY_SESSION_ID, session.getSessionId()); eventData.put(ChargeSessionManager.EVENT_PROPERTY_SOCKET_ID, session.getSocketId()); List<ChargeSessionMeterReading> readings = chargeSessionManager .meterReadingsForChargeSession(session.getSessionId()); if ( readings != null && !readings.isEmpty() ) { int left = 2; for ( ChargeSessionMeterReading reading : readings ) { if ( ReadingContext.TRANSACTION_BEGIN.equals(reading.getContext()) ) { if ( Measurand.POWER_ACTIVE_IMPORT.equals(reading.getMeasurand()) ) { Integer power = Integer.valueOf(reading.getValue()); eventData.put(ChargeSessionManager.EVENT_PROPERTY_METER_READING_POWER, power); left--; } else if ( Measurand.ENERGY_ACTIVE_IMPORT_REGISTER .equals(reading.getMeasurand()) ) { Long energy = Long.valueOf(reading.getValue()); eventData.put(ChargeSessionManager.EVENT_PROPERTY_METER_READING_ENERGY, energy); left--; } } if ( left < 1 ) { break; } } } handleEvent(new Event(ChargeSessionManager.EVENT_TOPIC_SESSION_STARTED, eventData)); } @Override public void refreshKioskData() { refreshKioskPvData(); refreshKioskSocketData(); postMessage(MESSAGE_TOPIC_KIOSK_DATA, kioskData); } private void refreshKioskPvData() { int totalPower = 0; for ( AtomicInteger p : pvPowerMap.values() ) { totalPower += p.get(); } AtomicInteger total = (AtomicInteger) pvDataMap.get("power"); total.set(totalPower); } private void refreshKioskSocketData() { if ( socketConfigurations == null || socketConfigurations.isEmpty() ) { return; } if ( sessionDataRefreshNeeded.get() ) { refreshSessionDataForConfiguredSockets(); } for ( SocketConfiguration socketConf : socketConfigurations ) { final String socketId = socketConf.getSocketId(); if ( socketId == null ) { continue; } final Map<String, Object> sessionData = sessionDataForSocket(socketId); if ( sessionData == null ) { continue; } // get socket activation state DatumDataSource<ACEnergyDatum> meterDataSource = socketMeterDataSources.get(socketId); if ( meterDataSource == null ) { for ( DatumDataSource<ACEnergyDatum> ds : meterDataSources ) { if ( socketConf.getMeterDataSourceUID().equals(ds.getUID()) ) { meterDataSource = ds; // cache the data source mapping as we don't expect it to change socketMeterDataSources.put(socketId, ds); break; } } } if ( meterDataSource == null ) { log.warn("Meter data source {} not available for socket {}", socketConf.getMeterDataSourceUID(), socketId); continue; } // get meter readings for this socket ACEnergyDatum meterData = meterDataSource.readCurrentDatum(); if ( meterData != null ) { updateSessionData(sessionData, meterData.getWatts(), meterData.getWattHourReading(), null); } } } private boolean configureKioskRefreshJob(final long interval) { final Scheduler sched = scheduler; if ( sched == null ) { log.warn("No scheduler avaialable, cannot schedule OCPP kiosk refresh job"); return false; } final JobKey jobKey = new JobKey(KIOSK_REFRESH_JOB_NAME, SCHEDULER_GROUP); final TriggerKey triggerKey = new TriggerKey(KIOSK_REFRESH_JOB_NAME, SCHEDULER_GROUP); SimpleTrigger trigger = refreshKioskDataTrigger; if ( trigger != null ) { // check if interval actually changed if ( trigger.getRepeatInterval() == interval ) { log.debug("OCPP kiosk refresh interval unchanged at {}s", interval); return true; } // trigger has changed! if ( interval == 0 ) { try { sched.deleteJob(jobKey); log.info("Unscheduled OCPP kiosk refresh job"); } catch ( SchedulerException e ) { log.error("Error unscheduling OCPP kiosk refresh job", e); } finally { refreshKioskDataTrigger = null; } } else { trigger = TriggerBuilder.newTrigger().withIdentity(trigger.getKey()).forJob(jobKey) .withSchedule( SimpleScheduleBuilder.repeatMinutelyForever((int) (interval / (60000L)))) .build(); try { sched.rescheduleJob(trigger.getKey(), trigger); } catch ( SchedulerException e ) { log.error("Error rescheduling Loxone datum logger job", e); } finally { refreshKioskDataTrigger = null; } } return true; } else if ( interval == 0 ) { return true; } synchronized ( sched ) { try { JobDetail jobDetail = sched.getJobDetail(jobKey); if ( jobDetail == null ) { jobDetail = JobBuilder.newJob(KioskDataServiceRefreshJob.class).withIdentity(jobKey) .storeDurably().build(); sched.addJob(jobDetail, true); } trigger = TriggerBuilder.newTrigger().withIdentity(triggerKey).forJob(jobKey) .startAt(new Date(System.currentTimeMillis() + interval)) .usingJobData(new JobDataMap(Collections.singletonMap("dataService", this))) .withSchedule( SimpleScheduleBuilder.repeatSecondlyForever((int) (interval / (1000L))) .withMisfireHandlingInstructionNextWithExistingCount()) .build(); sched.scheduleJob(trigger); log.info("Scheduled OCPP kiosk refresh job to run every {} seconds", (interval / 1000)); refreshKioskDataTrigger = trigger; return true; } catch ( Exception e ) { log.error("Error scheduling OCPP kiosk refresh job", e); return false; } } } @Override public String getSettingUID() { return getClass().getName(); } @Override public String getDisplayName() { return "OCPP Kiosk Data Service"; } @Override public MessageSource getMessageSource() { return messageSource; } public void setMessageSource(MessageSource messageSource) { this.messageSource = messageSource; } @Override public List<SettingSpecifier> getSettingSpecifiers() { List<SettingSpecifier> results = new ArrayList<SettingSpecifier>(3); results.add(new BasicTextFieldSettingSpecifier( "filterableChargeSessionManager.propertyFilters['UID']", "OCPP Central System")); results.add(new BasicTextFieldSettingSpecifier("pvSourceIds", "")); // dynamic list of SocketConfiguration Collection<SocketConfiguration> socketConfs = getSocketConfigurations(); BasicGroupSettingSpecifier socketConfsGroup = SettingsUtil.dynamicListSettingSpecifier( "socketConfigurations", socketConfs, new SettingsUtil.KeyedListCallback<SocketConfiguration>() { @Override public Collection<SettingSpecifier> mapListSettingKey(SocketConfiguration value, int index, String key) { BasicGroupSettingSpecifier socketConfGroup = new BasicGroupSettingSpecifier( value.settings(key + ".")); return Collections.<SettingSpecifier> singletonList(socketConfGroup); } }); results.add(socketConfsGroup); return results; } public FilterableService getFilterableChargeSessionManager() { return (chargeSessionManager instanceof FilterableService ? (FilterableService) chargeSessionManager : null); } /** * Set the collection of all available meter data sources from which to find * the appropriate ones to associate with each configured socket. * * @param meterDataSources * The collection of meter data sources. */ public void setMeterDataSources(Collection<DatumDataSource<ACEnergyDatum>> meterDataSources) { this.meterDataSources = meterDataSources; } /** * Set the charge session manager to use. * * @param chargeSessionManager * The charge session manager. */ public void setChargeSessionManager(ChargeSessionManager chargeSessionManager) { this.chargeSessionManager = chargeSessionManager; } public List<SocketConfiguration> getSocketConfigurations() { return socketConfigurations; } public void setSocketConfigurations(List<SocketConfiguration> socketConfigurations) { this.socketConfigurations = socketConfigurations; } /** * Get the number of configured {@code socketConfigurations} elements. * * @return The number of {@code socketConfigurations} elements. */ public int getSocketConfigurationsCount() { List<SocketConfiguration> l = getSocketConfigurations(); return (l == null ? 0 : l.size()); } /** * Adjust the number of configured {@code socketConfigurations} elements. * * @param count * The desired number of {@code socketConfigurations} elements. */ public void setSocketConfigurationsCount(int count) { if ( count < 0 ) { count = 0; } List<SocketConfiguration> l = getSocketConfigurations(); int lCount = (l == null ? 0 : l.size()); while ( lCount > count ) { l.remove(l.size() - 1); lCount--; } while ( lCount < count ) { if ( l == null ) { l = new ArrayList<SocketConfiguration>(count); setSocketConfigurations(l); } l.add(new SocketConfiguration()); lCount++; } } public void setMessageSendingOps(OptionalService<SimpMessageSendingOperations> messageSendingOps) { this.messageSendingOps = messageSendingOps; } public void setScheduler(Scheduler scheduler) { this.scheduler = scheduler; } public Set<String> getPvSourceIdSet() { return pvSourceIdSet; } public void setPvSourceIdSet(Set<String> pvSourceIdSet) { this.pvSourceIdSet = pvSourceIdSet; } /** * Get the {@code pvSourceIdSet} as a comma-delimited string. * * @return The {@link #getPvSourceIdSet()} as a delimited string. */ public String getPvSourceIds() { return StringUtils.commaDelimitedStringFromCollection(this.pvSourceIdSet); } /** * Set the {@code pvSourceIdSet} from a comma-delimited string. * * @param value * A comma-delimited string to parse and pass to * {@link #setPvSourceIdSet(Set)}. */ public void setPvSourceIds(String value) { this.pvSourceIdSet = StringUtils.commaDelimitedStringToSet(value); } /** * Set a {@link TaskExecutor} to handle asynchronous operations with. * * @param taskExecutor * The task executor. */ public void setTaskExecutor(TaskExecutor taskExecutor) { this.taskExecutor = taskExecutor; } }