/* ==================================================================
* ChargeSessionManager_v15.java - 9/06/2015 11:00:33 am
*
* Copyright 2007-2015 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.charge;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventAdmin;
import org.osgi.service.event.EventHandler;
import org.quartz.JobDataMap;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SimpleTrigger;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import net.solarnetwork.node.Constants;
import net.solarnetwork.node.DatumDataSource;
import net.solarnetwork.node.MultiDatumDataSource;
import net.solarnetwork.node.domain.ACEnergyDatum;
import net.solarnetwork.node.domain.GeneralNodeACEnergyDatum;
import net.solarnetwork.node.ocpp.AuthorizationManager;
import net.solarnetwork.node.ocpp.CentralSystemServiceFactory;
import net.solarnetwork.node.ocpp.ChargeConfiguration;
import net.solarnetwork.node.ocpp.ChargeConfigurationDao;
import net.solarnetwork.node.ocpp.ChargeSession;
import net.solarnetwork.node.ocpp.ChargeSessionDao;
import net.solarnetwork.node.ocpp.ChargeSessionManager;
import net.solarnetwork.node.ocpp.ChargeSessionMeterReading;
import net.solarnetwork.node.ocpp.OCPPException;
import net.solarnetwork.node.ocpp.Socket;
import net.solarnetwork.node.ocpp.SocketDao;
import net.solarnetwork.node.ocpp.support.CentralSystemServiceFactorySupport;
import net.solarnetwork.node.settings.SettingSpecifier;
import net.solarnetwork.node.settings.support.BasicTextFieldSettingSpecifier;
import net.solarnetwork.node.util.ClassUtils;
import net.solarnetwork.util.FilterableService;
import net.solarnetwork.util.OptionalService;
import net.solarnetwork.util.OptionalServiceCollection;
import net.solarnetwork.util.StringUtils;
import ocpp.v15.cs.AuthorizationStatus;
import ocpp.v15.cs.CentralSystemService;
import ocpp.v15.cs.ChargePointErrorCode;
import ocpp.v15.cs.ChargePointStatus;
import ocpp.v15.cs.IdTagInfo;
import ocpp.v15.cs.Measurand;
import ocpp.v15.cs.MeterValue;
import ocpp.v15.cs.MeterValue.Value;
import ocpp.v15.cs.MeterValuesRequest;
import ocpp.v15.cs.ReadingContext;
import ocpp.v15.cs.StartTransactionRequest;
import ocpp.v15.cs.StartTransactionResponse;
import ocpp.v15.cs.StatusNotificationRequest;
import ocpp.v15.cs.StatusNotificationResponse;
import ocpp.v15.cs.StopTransactionRequest;
import ocpp.v15.cs.StopTransactionResponse;
import ocpp.v15.cs.TransactionData;
import ocpp.v15.cs.UnitOfMeasure;
/**
* Default implementation of {@link ChargeSessionManager}.
*
* @author matt
* @version 2.2
*/
public class ChargeSessionManager_v15 extends CentralSystemServiceFactorySupport
implements ChargeSessionManager, ChargeSessionManager_v15Settings, EventHandler {
/**
* The name used to schedule the {@link PostOfflineChargeSessionsJob} as.
*/
public static final String POST_OFFLINE_CHARGE_SESSIONS_JOB_NAME = "OCPP_PostOfflineChargeSessions";
/**
* The name used to schedule the {@link CloseCompletedChargeSessionsJob} as.
*/
public static final String CLOSE_COMPLETED_CHARGE_SESSIONS_JOB_NAME = "OCPP_CloseCompletedChargeSessions";
/**
* The name used to schedule the
* {@link PostActiveChargeSessionsMeterValuesJob} as.
*/
public static final String CLOSE_POST_ACTIVE_CHARGE_SESSIONS_METER_VALUES_JOB_NAME = "OCPP_PostActiveChargeSessionsMeterValues";
/**
* The job and trigger group used to schedule the
* {@link PostOfflineChargeSessionsJob} with. Note the trigger name will be
* the {@link #getUID()} property value.
*/
public static final String SCHEDULER_GROUP = "OCPP";
/**
* The interval at which to try posting offline charge session data to the
* central system, in seconds.
*/
public static final int POST_OFFLINE_CHARGE_SESSIONS_JOB_INTERVAL = 600;
/**
* The interval at which to try posting active charge session meter value
* data to the central system, in sesconds.
*/
public static final int POST_ACTIVE_CHARGE_SESSIONS_METER_VALUES_JOB_INTERVAL = 600;
/**
* The interval at which to try closing sessions that appear to be completed
* but are still active.
*/
public static final int CLOSE_COMPLETED_CHARGE_SESSIONS_JOB_INTERVAL = 600;
private OptionalService<EventAdmin> eventAdmin;
private AuthorizationManager authManager;
private ChargeConfigurationDao chargeConfigurationDao;
private ChargeSessionDao chargeSessionDao;
private SocketDao socketDao;
private TransactionTemplate transactionTemplate;
private Map<String, Integer> socketConnectorMapping = Collections.emptyMap();
private Map<String, String> socketMeterSourceMapping = Collections.emptyMap();
private OptionalServiceCollection<DatumDataSource<ACEnergyDatum>> meterDataSource;
private Scheduler scheduler;
private SimpleTrigger postOfflineChargeSessionsTrigger;
private SimpleTrigger closeCompletedChargeSessionsTrigger;
private SimpleTrigger postActiveChargeSessionsMeterValuesTrigger;
private final ConcurrentMap<String, Object> socketReadingsIgnoreMap = new ConcurrentHashMap<String, Object>(
8);
/**
* Initialize the OCPP client. Call this once after all properties
* configured.
*/
@Override
public void startup() {
log.info("Starting up OCPP ChargeSessionManager {}", getUID());
configurePostOfflineChargeSessionsJob(POST_OFFLINE_CHARGE_SESSIONS_JOB_INTERVAL);
configureCloseCompletedChargeSessionJob(CLOSE_COMPLETED_CHARGE_SESSIONS_JOB_INTERVAL);
// configure aspects from OCPP properties
handleChargeConfigurationUpdated();
}
/**
* Shutdown the OCPP client, releasing any associated resources.
*/
@Override
public void shutdown() {
configurePostOfflineChargeSessionsJob(0);
configureCloseCompletedChargeSessionJob(0);
configurePostActiveChargeSessionsMeterValuesJob(0);
}
@Override
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public ChargeSession activeChargeSession(String socketId) {
return chargeSessionDao.getIncompleteChargeSessionForSocket(socketId);
}
@Override
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public ChargeSession activeChargeSession(Number transactionId) {
int xid = transactionId.intValue();
return chargeSessionDao.getIncompleteChargeSessionForTransaction(xid);
}
@Override
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public Collection<String> availableSocketIds() {
return new HashSet<String>(socketConnectorMapping.keySet());
}
@Override
@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
public String initiateChargeSession(final String idTag, final String socketId,
final Integer reservationId) {
final Integer connectorId = socketConnectorMapping.get(socketId);
if ( connectorId == null ) {
log.error("No connector ID configured for socket ID {}", socketId);
throw new OCPPException("No connector ID available for " + socketId);
}
// is the socket enabled?
if ( socketDao.isEnabled(socketId) == false ) {
log.info("Socket {} is disabled, not initiating charge session for {}", socketId, idTag);
throw new OCPPException("Socket disabled", null, AuthorizationStatus.BLOCKED);
}
// is there an active session already? if so, DENY
ChargeSession session = activeChargeSession(socketId);
if ( session != null ) {
throw new OCPPException(
"An active charge session exists already on " + session.getSocketId(), null,
AuthorizationStatus.CONCURRENT_TX);
}
final long now = System.currentTimeMillis();
final Object socketLock = ignoreReadingsForSocket(socketId);
synchronized ( socketLock ) {
try {
final AuthorizationStatus authorized = authManager.authorize(idTag);
log.debug("{} authorized: {}", idTag, authorized);
if ( authorized != AuthorizationStatus.ACCEPTED ) {
throw new OCPPException("Unauthorized", null, authorized);
}
final String meterSourceId = socketMeterSourceMapping.get(socketId);
if ( meterSourceId == null ) {
log.warn(
"No meter source ID available for socket ID {}, starting meter value will not be available for charge session",
socketId);
}
// send status message
postStatusNotification(ChargePointStatus.OCCUPIED, connectorId, now);
final ACEnergyDatum meterReading = getMeterReading(meterSourceId);
session = new ChargeSession();
session.setCreated(new Date(now));
session.setIdTag(idTag);
session.setSocketId(socketId);
StartTransactionResponse res = postStartTransaction(idTag, reservationId, connectorId,
session, now, (meterReading != null ? meterReading.getWattHourReading() : null));
if ( res != null && res.getIdTagInfo() != null
&& res.getIdTagInfo().getStatus() == AuthorizationStatus.ACCEPTED ) {
final String sessionId = chargeSessionDao.storeChargeSession(session);
// insert transaction begin readings
List<Value> readings = readingsForDatum(meterReading);
for ( Value v : readings ) {
v.setContext(ReadingContext.TRANSACTION_BEGIN);
}
chargeSessionDao.addMeterReadings(sessionId,
(meterReading != null ? meterReading.getCreated() : new Date(now)),
readings);
postChargeSessionStateEvent(session, true, meterReading);
postConfigurationChangedEvent();
return sessionId;
}
throw new OCPPException("StartTransaction failed for IdTag " + idTag, null,
res != null && res.getIdTagInfo() != null ? res.getIdTagInfo().getStatus()
: null);
} finally {
resumeReadingsForSocket(socketId, socketLock);
}
}
}
private void postChargeSessionStateEvent(ChargeSession session, boolean started,
ACEnergyDatum datum) {
Map<String, Object> props = new HashMap<String, Object>(4);
if ( started && session.getCreated() != null ) {
props.put(EVENT_PROPERTY_DATE, session.getCreated().getTime());
} else if ( !started && session.getEnded() != null ) {
props.put(EVENT_PROPERTY_DATE, session.getEnded().getTime());
}
props.put(EVENT_PROPERTY_SESSION_ID, session.getSessionId());
props.put(EVENT_PROPERTY_SOCKET_ID, session.getSocketId());
if ( datum != null ) {
props.put(EVENT_PROPERTY_METER_READING_POWER, datum.getWatts());
props.put(EVENT_PROPERTY_METER_READING_ENERGY, datum.getWattHourReading());
}
postEvent(started ? EVENT_TOPIC_SESSION_STARTED : EVENT_TOPIC_SESSION_ENDED, props);
}
private void postConfigurationChangedEvent() {
postEvent(Constants.EVENT_TOPIC_CONFIGURATION_CHANGED, null);
}
private void postEvent(String topic, Map<String, Object> props) {
final EventAdmin admin = (eventAdmin != null ? eventAdmin.service() : null);
if ( admin == null ) {
return;
}
admin.postEvent(new Event(topic, props));
}
private StatusNotificationResponse postStatusNotification(final ChargePointStatus status,
final Integer connectorId, final long now) {
return postStatusNotification(status, connectorId, null, null, null, now);
}
/**
* Post a status notification update to the central system.
*
* @param status
* The status to post.
* @param connectorId
* The ID of the associated connector.
* @param info
* An optional info message.
* @param errorCode
* An optional error code. If not provided,
* {@link ChargePointErrorCode#NO_ERROR} will be used.
* @param internalErrorCode
* An optional internal error code.
* @param now
* A timestamp to use.
* @return The response, or <em>null</em> if not able to post the status.
*/
private StatusNotificationResponse postStatusNotification(final ChargePointStatus status,
final Integer connectorId, final String info, final ChargePointErrorCode errorCode,
final String internalErrorCode, final long now) {
final CentralSystemServiceFactory system = getCentralSystem();
final CentralSystemService client = (system != null ? system.service() : null);
StatusNotificationResponse res = null;
if ( client != null ) {
StatusNotificationRequest req = new StatusNotificationRequest();
req.setConnectorId(connectorId.intValue());
req.setInfo(info);
req.setStatus(status);
req.setErrorCode(errorCode != null ? errorCode : ChargePointErrorCode.NO_ERROR);
req.setVendorErrorCode(internalErrorCode);
req.setTimestamp(newXmlCalendar(now));
try {
res = client.statusNotification(req, system.chargeBoxIdentity());
log.info("OCPP central system status updated to {}", status);
} catch ( RuntimeException e ) {
// log the error, but we don't stop the session from starting
log.error("Error communicating with OCPP central system for StatusNotification", e);
}
}
return res;
}
/**
* Post the {@code StartTransaction} message.
*
* @param idTag
* The ID tag.
* @param reservationId
* An optional OCPP reservation ID.
* @param connectorId
* The OCPP connector ID.
* @param session
* The ChargeSession associated with the transaction.
* @param now
* The current time.
* @param meterReading
* An optional meter reading.
* @return The response, or <em>null</em> if no central system is available.
*/
private StartTransactionResponse postStartTransaction(String idTag, Integer reservationId,
final Integer connectorId, ChargeSession session, final long now,
final Number meterReading) {
final CentralSystemServiceFactory system = getCentralSystem();
final CentralSystemService client = (system != null ? system.service() : null);
StartTransactionResponse res = null;
if ( client != null ) {
StartTransactionRequest req = new StartTransactionRequest();
req.setConnectorId(connectorId);
req.setIdTag(idTag);
req.setReservationId(reservationId);
req.setTimestamp(newXmlCalendar(now));
if ( meterReading != null ) {
req.setMeterStart(meterReading.intValue());
}
try {
res = client.startTransaction(req, system.chargeBoxIdentity());
IdTagInfo info = res.getIdTagInfo();
AuthorizationStatus status = (info != null ? res.getIdTagInfo().getStatus() : null);
session.setStatus(status);
session.setParentIdTag(info != null ? info.getParentIdTag() : null);
session.setExpiryDate(info != null ? info.getExpiryDate() : null);
if ( res.getIdTagInfo() != null && AuthorizationStatus.ACCEPTED.equals(status) ) {
session.setTransactionId(res.getTransactionId());
}
} catch ( RuntimeException e ) {
// log the error, but we don't stop the session from starting
log.error("Error communicating with OCPP central system for StartTransaction", e);
}
}
return res;
}
@Override
@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
public void completeChargeSession(String idTag, String sessionId) {
// get active session
ChargeSession session = chargeSessionDao.getChargeSession(sessionId);
if ( session == null ) {
throw new OCPPException("No such charge session", null, AuthorizationStatus.INVALID);
}
if ( session.getEnded() != null ) {
throw new OCPPException("Session already complete", null, AuthorizationStatus.EXPIRED);
}
if ( session.getIdTag() == null || !session.getIdTag().equals(idTag) ) {
throw new OCPPException("IdTag does not match", null, AuthorizationStatus.INVALID);
}
final long now = System.currentTimeMillis();
final String socketId = session.getSocketId();
final Integer connectorId = socketConnectorMapping.get(socketId);
if ( connectorId == null ) {
log.error("No connector ID configured for socket ID {}", socketId);
throw new OCPPException("No connector ID available for " + socketId);
}
// mark this socket as "stopping" so the subsequent meter reading doesn't get added
final Object socketLock = ignoreReadingsForSocket(socketId);
synchronized ( socketLock ) {
ACEnergyDatum meterReading = null;
try {
// get current meter reading
final String meterSourceId = socketMeterSourceMapping.get(socketId);
if ( meterSourceId == null ) {
log.warn(
"No meter source ID available for socket ID {}, final meter value will not be available for charge session",
session.getSocketId());
}
meterReading = getMeterReading(meterSourceId);
// add end transaction readings
List<Value> readings = readingsForDatum(meterReading);
for ( Value v : readings ) {
v.setContext(ReadingContext.TRANSACTION_END);
}
chargeSessionDao.addMeterReadings(sessionId,
(meterReading != null ? meterReading.getCreated() : new Date(now)), readings);
// post the stop transaction, if we have a transaction ID
postStopTransaction(idTag, session, now,
(meterReading != null ? meterReading.getWattHourReading() : null));
// persist changes to DB
session.setEnded(new Date(now));
chargeSessionDao.storeChargeSession(session);
} finally {
postChargeSessionStateEvent(session, false, meterReading);
postStatusNotification(ChargePointStatus.AVAILABLE, connectorId, now);
resumeReadingsForSocket(socketId, socketLock);
postConfigurationChangedEvent();
}
}
}
/**
* Post a {@code StopTransaction} message to the central system. If the
* message is posted successfully, then the {@link IdTagInfo#getStatus()}
* value will be passed to
* {@link ChargeSession#setStatus(AuthorizationStatus)}.
*
* @param idTag
* The ID tag.
* @param session
* The active session that is stopping.
* @param now
* The current date.
* @param meterReading
* An optional meter reading, to populate the {@code meterStop} value
* with.
* @return The response, or <em>null</em> if no transaction ID available or
* the central system is not available.
*/
private StopTransactionResponse postStopTransaction(String idTag, ChargeSession session,
final long now, final Number meterReading) {
CentralSystemServiceFactory system = getCentralSystem();
CentralSystemService client = (system != null ? system.service() : null);
StopTransactionResponse res = null;
if ( session.getTransactionId() != null && client != null ) {
StopTransactionRequest req = new StopTransactionRequest();
req.setIdTag(idTag);
if ( meterReading != null ) {
req.setMeterStop(meterReading.intValue());
}
req.setTimestamp(newXmlCalendar(now));
req.setTransactionId(session.getTransactionId());
// add any associated readings
List<ChargeSessionMeterReading> readings = chargeSessionDao
.findMeterReadingsForSession(session.getSessionId());
TransactionData data = transactionDataForMeterReadings(readings);
if ( data.getValues().size() > 0 ) {
req.getTransactionData().add(data);
}
res = client.stopTransaction(req, system.chargeBoxIdentity());
if ( res.getIdTagInfo() != null ) {
IdTagInfo info = res.getIdTagInfo();
if ( info.getStatus() != null ) {
session.setStatus(info.getStatus());
}
session.setPosted(new Date(now));
}
}
return res;
}
private TransactionData transactionDataForMeterReadings(List<ChargeSessionMeterReading> readings) {
TransactionData data = new TransactionData();
MeterValue currMeterValue = null;
long currTimestamp = -1;
for ( ChargeSessionMeterReading r : readings ) {
if ( r.getTs().getTime() != currTimestamp ) {
currMeterValue = new MeterValue();
data.getValues().add(currMeterValue);
currTimestamp = r.getTs().getTime();
currMeterValue.setTimestamp(newXmlCalendar(currTimestamp));
}
currMeterValue.getValue().add(r);
}
return data;
}
@Override
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public List<ChargeSessionMeterReading> meterReadingsForChargeSession(String sessionId) {
return chargeSessionDao.findMeterReadingsForSession(sessionId);
}
@Override
public String socketIdForConnectorId(Number connectorId) {
Integer connId = (connectorId != null ? connectorId.intValue() : null);
if ( connId == null ) {
return null;
}
Map<String, Integer> map = getSocketConnectorMapping();
if ( map == null ) {
return null;
}
for ( Map.Entry<String, Integer> me : map.entrySet() ) {
if ( connId.equals(me.getValue()) ) {
return me.getKey();
}
}
return null;
}
@Override
@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
public void configureSocketEnabledState(final Collection<String> socketIds, final boolean enabled) {
for ( String socketId : socketIds ) {
boolean existing = socketDao.isEnabled(socketId);
if ( existing == enabled ) {
continue;
}
socketDao.storeSocket(new Socket(socketId, enabled));
}
}
@Override
@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
public int postCompleteOfflineSessions(final int max) {
List<ChargeSession> toPost = chargeSessionDao.getChargeSessionsNeedingPosting(max);
for ( ChargeSession session : toPost ) {
Integer connectorId = getSocketConnectorMapping().get(session.getSocketId());
List<ChargeSessionMeterReading> readings = chargeSessionDao
.findMeterReadingsForSession(session.getSessionId());
Long startWh = null;
Long endWh = null;
for ( ChargeSessionMeterReading reading : readings ) {
if ( Measurand.ENERGY_ACTIVE_IMPORT_REGISTER.equals(reading.getMeasurand()) ) {
if ( ReadingContext.TRANSACTION_BEGIN.equals(reading.getContext()) ) {
startWh = Long.valueOf(reading.getValue());
} else if ( ReadingContext.TRANSACTION_END.equals(reading.getContext()) ) {
endWh = Long.valueOf(reading.getValue());
}
}
}
if ( session.getTransactionId() == null ) {
StartTransactionResponse resp = postStartTransaction(session.getIdTag(), null,
connectorId, session, System.currentTimeMillis(), startWh);
if ( resp != null && resp.getIdTagInfo() != null
&& AuthorizationStatus.ACCEPTED.equals(resp.getIdTagInfo().getStatus()) ) {
session.setTransactionId(resp.getTransactionId());
chargeSessionDao.storeChargeSession(session);
}
}
if ( session.getEnded() != null && session.getPosted() == null ) {
final Date postDate = new Date();
StopTransactionResponse resp = postStopTransaction(session.getIdTag(), session,
postDate.getTime(), endWh);
if ( resp != null ) {
chargeSessionDao.storeChargeSession(session);
}
}
}
return toPost.size();
}
@Override
@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
public void postActiveChargeSessionsMeterValues() {
final CentralSystemServiceFactory system = getCentralSystem();
final CentralSystemService client = (system != null ? system.service() : null);
if ( client == null ) {
return;
}
for ( String socketId : availableSocketIds() ) {
final ChargeSession session = activeChargeSession(socketId);
if ( session == null ) {
continue;
}
final Integer connectorId = socketConnectorMapping.get(socketId);
List<ChargeSessionMeterReading> readings = meterReadingsForChargeSession(
session.getSessionId());
TransactionData data = transactionDataForMeterReadings(readings);
final int valuesCount = data.getValues().size();
if ( valuesCount > 0 ) {
// post just the most recent (last) value available
MeterValuesRequest req = new MeterValuesRequest();
req.getValues().add(data.getValues().get(valuesCount - 1));
req.setConnectorId(connectorId);
req.setTransactionId(session.getTransactionId());
client.meterValues(req, system.chargeBoxIdentity());
log.info(
"Posted {} meter values for active charge session {} on socket {} to OCPP central server",
data.getValues().size(), session.getSessionId(), socketId);
}
}
}
private Map<String, Object> standardJobMap() {
Map<String, Object> jobMap = new HashMap<String, Object>();
jobMap.put("service", this);
if ( transactionTemplate != null ) {
jobMap.put("transactionTemplate", transactionTemplate);
}
return jobMap;
}
private boolean configurePostOfflineChargeSessionsJob(final int seconds) {
postOfflineChargeSessionsTrigger = scheduleIntervalJob(scheduler, seconds,
postOfflineChargeSessionsTrigger,
new JobKey(POST_OFFLINE_CHARGE_SESSIONS_JOB_NAME, SCHEDULER_GROUP),
PostOfflineChargeSessionsJob.class, new JobDataMap(standardJobMap()),
"OCPP post offline charge sessions");
return ((seconds > 0 && postOfflineChargeSessionsTrigger != null)
|| (seconds < 1 && postOfflineChargeSessionsTrigger == null));
}
private boolean configureCloseCompletedChargeSessionJob(final int seconds) {
closeCompletedChargeSessionsTrigger = scheduleIntervalJob(scheduler, seconds,
closeCompletedChargeSessionsTrigger,
new JobKey(CLOSE_COMPLETED_CHARGE_SESSIONS_JOB_NAME, SCHEDULER_GROUP),
CloseCompletedChargeSessionsJob.class, new JobDataMap(standardJobMap()),
"OCPP close completed charge sessions");
return ((seconds > 0 && closeCompletedChargeSessionsTrigger != null)
|| (seconds < 1 && closeCompletedChargeSessionsTrigger == null));
}
private boolean configurePostActiveChargeSessionsMeterValuesJob(final int seconds) {
postActiveChargeSessionsMeterValuesTrigger = scheduleIntervalJob(scheduler, seconds,
postActiveChargeSessionsMeterValuesTrigger,
new JobKey(CLOSE_POST_ACTIVE_CHARGE_SESSIONS_METER_VALUES_JOB_NAME, SCHEDULER_GROUP),
PostActiveChargeSessionsMeterValuesJob.class, new JobDataMap(standardJobMap()),
"OCPP post active charge sessions meter values");
return ((seconds > 0 && postActiveChargeSessionsMeterValuesTrigger != null)
|| (seconds < 1 && postActiveChargeSessionsMeterValuesTrigger == null));
}
// Datum support
private ACEnergyDatum getMeterReading(String sourceId) {
OptionalServiceCollection<DatumDataSource<ACEnergyDatum>> service = meterDataSource;
if ( service == null || sourceId == null ) {
return null;
}
Iterable<DatumDataSource<ACEnergyDatum>> dataSources = service.services();
for ( DatumDataSource<ACEnergyDatum> dataSource : dataSources ) {
if ( dataSource instanceof MultiDatumDataSource<?> ) {
@SuppressWarnings("unchecked")
Collection<ACEnergyDatum> datums = ((MultiDatumDataSource<ACEnergyDatum>) dataSource)
.readMultipleDatum();
if ( datums != null ) {
for ( ACEnergyDatum datum : datums ) {
if ( sourceId.equals(datum.getSourceId()) ) {
return datum;
}
}
}
} else {
ACEnergyDatum datum = dataSource.readCurrentDatum();
if ( datum != null && sourceId.equals(sourceId) ) {
return datum;
}
}
}
log.warn("Meter reading unavailable for source {}", sourceId);
return null;
}
/**
* Mark a socket to ignore meter readings.
*
* @param socketId
* The socket ID to ignore readings from, or to stop ignoring.
* @return An object suitable for synchronizing on and that must be passed
* to {@link #resumeReadingsForSocket(String, Object)} to clear the
* ignore flag.
* @see #shouldIgnoreReadingsForSocket(String)
* @see #resumeReadingsForSocket(String, Object)
*/
private Object ignoreReadingsForSocket(final String socketId) {
Object lock = new Object();
Object existingLock = socketReadingsIgnoreMap.putIfAbsent(socketId, lock);
return (existingLock != null ? existingLock : lock);
}
/**
* Clear the ignore flag previously set via
* {@link #ignoreReadingsForSocket(String)}.
*
* @param socketId
* The socket ID to resume listening to.
* @param socketLock
* The lock previously returned from
* {@link #ignoreReadingsForSocket(String)}.
*/
private void resumeReadingsForSocket(final String socketId, final Object socketLock) {
if ( socketLock != null ) {
socketReadingsIgnoreMap.remove(socketId, socketLock);
}
}
/**
* Test if a socket ID is marked as "stopping" from a previous call to
* {@link #markSocketAsStopping(String)}.
*
* @param socketId
* The socket ID to test.
* @return <em>true</em> if the socket is considered in "stopping" mode.
*/
private boolean shouldIgnoreReadingsForSocket(String socketId) {
return socketReadingsIgnoreMap.containsKey(socketId);
}
// EventHandler
@Override
public void handleEvent(Event event) {
final String topic = event.getTopic();
try {
if ( topic.equals(DatumDataSource.EVENT_TOPIC_DATUM_CAPTURED) ) {
handleDatumCapturedEvent(event);
} else if ( topic.equals(ChargeConfigurationDao.EVENT_TOPIC_CHARGE_CONFIGURATION_UPDATED) ) {
handleChargeConfigurationUpdated();
}
} catch ( RuntimeException e ) {
log.error("Error handling event {}", topic, e);
}
}
private Map<String, Object> mapForEventProperties(Event event) {
Map<String, Object> map = new HashMap<String, Object>(8);
if ( event != null ) {
for ( String name : event.getPropertyNames() ) {
Object o = event.getProperty(name);
map.put(name, o);
}
}
return map;
}
private void handleDatumCapturedEvent(Event event) {
Map<String, Object> eventProperties = mapForEventProperties(event);
Object propValue = eventProperties.get("sourceId");
String sourceId;
if ( propValue instanceof String ) {
sourceId = (String) propValue;
} else {
return;
}
log.debug("Received datum captured event: {}", eventProperties);
// locate the socket ID for the given source ID
for ( Map.Entry<String, String> me : socketMeterSourceMapping.entrySet() ) {
if ( sourceId.equals(me.getValue()) ) {
handleDatumCapturedEvent(me.getKey(), sourceId, eventProperties);
return;
}
}
}
private void handleDatumCapturedEvent(String socketId, String sourceId,
Map<String, Object> eventProperties) {
if ( shouldIgnoreReadingsForSocket(socketId) ) {
log.debug("Ignoring DATUM_CAPTURED event for socket {} that is transitioning state",
socketId);
return;
}
ChargeSession active = activeChargeSession(socketId);
if ( active == null ) {
return;
}
final long created = (eventProperties.get("created") instanceof Number
? ((Number) eventProperties.get("created")).longValue() : System.currentTimeMillis());
// reconstruct Datum from event properties
GeneralNodeACEnergyDatum datum = new GeneralNodeACEnergyDatum();
ClassUtils.setBeanProperties(datum, eventProperties, true);
// store readings in DB
List<Value> readings = readingsForDatum(datum);
chargeSessionDao.addMeterReadings(active.getSessionId(), new Date(created), readings);
}
private void handleChargeConfigurationUpdated() {
ChargeConfiguration config = chargeConfigurationDao.getChargeConfiguration();
if ( config.getMeterValueSampleInterval() >= 0 ) {
configurePostActiveChargeSessionsMeterValuesJob(config.getMeterValueSampleInterval());
}
}
private List<Value> readingsForDatum(ACEnergyDatum datum) {
List<Value> readings = new ArrayList<Value>(4);
if ( datum != null ) {
if ( datum.getWattHourReading() != null ) {
Value reading = new Value();
reading.setContext(ReadingContext.SAMPLE_PERIODIC);
reading.setMeasurand(Measurand.ENERGY_ACTIVE_IMPORT_REGISTER);
reading.setUnit(UnitOfMeasure.WH);
reading.setValue(datum.getWattHourReading().toString());
readings.add(reading);
}
if ( datum.getWatts() != null ) {
Value reading = new Value();
reading.setContext(ReadingContext.SAMPLE_PERIODIC);
reading.setMeasurand(Measurand.POWER_ACTIVE_IMPORT);
reading.setUnit(UnitOfMeasure.W);
reading.setValue(datum.getWatts().toString());
readings.add(reading);
}
}
return readings;
}
// SettingSpecifierProvider
@Override
public String getSettingUID() {
return "net.solarnetwork.node.ocpp.charge";
}
@Override
public String getDisplayName() {
return "OCPP Charge Session Manager";
}
@Override
public List<SettingSpecifier> getSettingSpecifiers() {
List<SettingSpecifier> results = super.getSettingSpecifiers();
ChargeSessionManager_v15 defaults = new ChargeSessionManager_v15();
results.add(new BasicTextFieldSettingSpecifier("filterableAuthManager.propertyFilters['UID']",
"OCPP Central System"));
results.add(new BasicTextFieldSettingSpecifier("meterDataSource.propertyFilters['groupUID']",
"OCPP Meter"));
results.add(new BasicTextFieldSettingSpecifier("socketMeterSourceMappingValue",
defaults.getSocketMeterSourceMappingValue()));
results.add(new BasicTextFieldSettingSpecifier("socketConnectorMappingValue",
defaults.getSocketConnectorMappingValue()));
return results;
}
@Override
protected String getInfoMessage(Locale locale) {
StringBuilder buf = new StringBuilder();
if ( chargeSessionDao != null ) {
List<ChargeSession> incomplete = chargeSessionDao.getIncompleteChargeSessions();
Set<String> active = new LinkedHashSet<String>(incomplete.size());
if ( incomplete.size() > 0 ) {
for ( ChargeSession s : incomplete ) {
active.add(s.getSessionId());
}
List<String> reversed = new ArrayList<String>(active);
Collections.reverse(reversed);
buf.append(
getMessageSource().getMessage("status.active",
new Object[] { incomplete.size(),
StringUtils.commaDelimitedStringFromCollection(reversed) },
locale));
}
List<ChargeSession> needPosting = chargeSessionDao.getChargeSessionsNeedingPosting(100);
if ( needPosting.size() > 0 ) {
List<String> need = new ArrayList<String>(needPosting.size());
for ( ChargeSession s : needPosting ) {
if ( active.contains(s.getSessionId()) ) {
continue;
}
need.add(s.getSessionId());
}
if ( buf.length() > 0 ) {
buf.append("; ");
}
String needIds = StringUtils.commaDelimitedStringFromCollection(
(need.size() > 10 ? need.subList(0, 10) : need));
buf.append(getMessageSource().getMessage("status.needPosting",
new Object[] { need.size(), needIds }, locale));
if ( need.size() > 10 ) {
buf.append("\u2026"); // ellipsis
}
}
}
if ( buf.length() < 1 ) {
buf.append(getMessageSource().getMessage("status.none", null, locale));
}
return buf.toString();
}
// Accessors
public AuthorizationManager getAuthManager() {
return authManager;
}
@Override
public FilterableService getFilterableAuthManager() {
AuthorizationManager mgr = authManager;
if ( mgr instanceof FilterableService ) {
return (FilterableService) mgr;
}
return null;
}
public void setAuthManager(AuthorizationManager authManager) {
this.authManager = authManager;
}
public ChargeSessionDao getChargeSessionDao() {
return chargeSessionDao;
}
public void setChargeSessionDao(ChargeSessionDao chargeSessionDao) {
this.chargeSessionDao = chargeSessionDao;
}
/**
* Get the mapping of SolarNode {@code socketId} values to corresponding
* OCPP {@code connectorId} values.
*
* @return The socket ID mapping, never <em>null</em>.
*/
public final Map<String, Integer> getSocketConnectorMapping() {
return socketConnectorMapping;
}
/**
* Set a mapping of SolarNode {@code socketId} values to corresponding OCPP
* {@code connectorId} values.
*
* @param socketConnectorMapping
* The mapping to use.
*/
public final void setSocketConnectorMapping(Map<String, Integer> socketConnectorMapping) {
this.socketConnectorMapping = (socketConnectorMapping != null ? socketConnectorMapping
: Collections.<String, Integer> emptyMap());
}
/**
* Set a {@code socketConnectorMapping} Map via an encoded String value.
*
* <p>
* The format of the {@code mapping} String should be:
* </p>
*
* <pre>
* key=val[,key=val,...]
* </pre>
*
* <p>
* Whitespace is permitted around all delimiters, and will be stripped from
* the keys and values.
* </p>
*
* @param mapping
* The encoding mapping to set.
* @see #getSocketConnectorMappingValue()
* @see #setSocketConnectorMapping(Map)
*/
@Override
public final void setSocketConnectorMappingValue(String mapping) {
Map<String, String> map = StringUtils.delimitedStringToMap(mapping, ",", "=");
if ( map == null || map.size() < 0 ) {
map = Collections.emptyMap();
}
Map<String, Integer> socketMap = new LinkedHashMap<String, Integer>(map.size());
for ( Map.Entry<String, String> me : map.entrySet() ) {
try {
Integer connId = Integer.valueOf(me.getValue());
socketMap.put(me.getKey(), connId);
} catch ( NumberFormatException e ) {
log.debug("Ignoring invalid connector ID {}, mapped from socket ID {}", me.getValue(),
me.getKey());
}
}
setSocketConnectorMapping(socketMap);
}
/**
* Get a delimited string representation of the
* {@link #getSocketConnectorMapping()} map.
*
* <p>
* The format of the {@code mapping} String should be:
* </p>
*
* <pre>
* key=val[,key=val,...]
* </pre>
*
* @return the encoded mapping
* @see #getSocketConnectorMapping()
*/
public final String getSocketConnectorMappingValue() {
return StringUtils.delimitedStringFromMap(socketConnectorMapping);
}
@Override
public OptionalServiceCollection<DatumDataSource<ACEnergyDatum>> getMeterDataSource() {
return meterDataSource;
}
public void setMeterDataSource(
OptionalServiceCollection<DatumDataSource<ACEnergyDatum>> meterDataSource) {
this.meterDataSource = meterDataSource;
}
/**
* Get
*
* @return
*/
public final Map<String, String> getSocketMeterSourceMapping() {
return socketMeterSourceMapping;
}
/**
* Set a mapping of SolarNode {@code socketId} values to corresponding
* SolarNode {@code sourceId} values representing the meter source to obtain
* meter data from.
*
* @param socketMeterSourceMapping
* The mapping to use.
*/
public final void setSocketMeterSourceMapping(Map<String, String> socketMeterSourceMapping) {
this.socketMeterSourceMapping = (socketMeterSourceMapping != null ? socketMeterSourceMapping
: Collections.<String, String> emptyMap());
}
/**
* Set a {@code socketMeterSourceMapping} Map via an encoded String value.
*
* <p>
* The format of the {@code mapping} String should be:
* </p>
*
* <pre>
* key=val[,key=val,...]
* </pre>
*
* <p>
* Whitespace is permitted around all delimiters, and will be stripped from
* the keys and values.
* </p>
*
* @param mapping
* The encoding mapping to set.
* @see #getSocketMeterSourceMappingValue()
* @see #setSocketMeterSourceMapping(Map)
*/
@Override
public final void setSocketMeterSourceMappingValue(String mapping) {
Map<String, String> map = StringUtils.delimitedStringToMap(mapping, ",", "=");
if ( map == null || map.size() < 0 ) {
map = Collections.emptyMap();
}
setSocketMeterSourceMapping(map);
}
/**
* Get a delimited string representation of the
* {@link #getSocketMeterSourceMapping()} map.
*
* <p>
* The format of the {@code mapping} String should be:
* </p>
*
* <pre>
* key=val[,key=val,...]
* </pre>
*
* @return the encoded mapping
* @see #getSocketMeterSourceMapping()
*/
public final String getSocketMeterSourceMappingValue() {
return StringUtils.delimitedStringFromMap(socketMeterSourceMapping);
}
public SocketDao getSocketDao() {
return socketDao;
}
public void setSocketDao(SocketDao socketDao) {
this.socketDao = socketDao;
}
/**
* Set the Scheduler to use for the {@link PostOfflineChargeSessionsJob}.
*
* @param scheduler
* The scheduler to use.
*/
public void setScheduler(Scheduler scheduler) {
this.scheduler = scheduler;
}
@Override
public void setUid(String uid) {
if ( uid != null && !uid.equals(getUid()) ) {
configurePostOfflineChargeSessionsJob(0);
super.setUid(uid);
configurePostOfflineChargeSessionsJob(POST_OFFLINE_CHARGE_SESSIONS_JOB_INTERVAL);
}
}
public void setEventAdmin(OptionalService<EventAdmin> eventAdmin) {
this.eventAdmin = eventAdmin;
}
public void setChargeConfigurationDao(ChargeConfigurationDao chargeConfigurationDao) {
this.chargeConfigurationDao = chargeConfigurationDao;
}
public void setTransactionTemplate(TransactionTemplate transactionTemplate) {
this.transactionTemplate = transactionTemplate;
}
}