/* ==================================================================
* ConfigurableCentralSystemServiceFactory.java - 6/06/2015 7:53:18 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.impl;
import java.net.URL;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLSocketFactory;
import javax.xml.namespace.QName;
import javax.xml.ws.BindingProvider;
import javax.xml.ws.handler.Handler;
import javax.xml.ws.soap.AddressingFeature;
import org.osgi.framework.Version;
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 net.solarnetwork.node.IdentityService;
import net.solarnetwork.node.ocpp.CentralSystemServiceFactory;
import net.solarnetwork.node.ocpp.ChargeConfiguration;
import net.solarnetwork.node.ocpp.ChargeConfigurationDao;
import net.solarnetwork.node.settings.SettingSpecifier;
import net.solarnetwork.node.settings.SettingSpecifierProvider;
import net.solarnetwork.node.settings.support.BasicTextFieldSettingSpecifier;
import net.solarnetwork.node.settings.support.BasicTitleSettingSpecifier;
import net.solarnetwork.node.settings.support.BasicToggleSettingSpecifier;
import net.solarnetwork.support.SSLService;
import net.solarnetwork.util.OptionalService;
import ocpp.v15.cs.BootNotificationRequest;
import ocpp.v15.cs.BootNotificationResponse;
import ocpp.v15.cs.CentralSystemService;
import ocpp.v15.cs.CentralSystemService_Service;
import ocpp.v15.cs.RegistrationStatus;
import ocpp.v15.support.HMACHandler;
import ocpp.v15.support.WSAddressingFromHandler;
/**
* Implementation of {@link CentralSystemServiceFactory} that allows configuring
* the service.
*
* @author matt
* @version 1.2
*/
public class ConfigurableCentralSystemServiceFactory
implements CentralSystemServiceFactory, SettingSpecifierProvider, EventHandler {
/** The name used to schedule the {@link HeartbeatJob} as. */
public static final String HEARTBEAT_JOB_NAME = "OCPP_Heartbeat";
/**
* The job and trigger group used to schedule the {@link HeartbeatJob} with.
* Note the trigger name will be the {@code url} property value.
*/
public static final String SCHEDULER_GROUP = "OCPP";
/**
* The default value for the {@code sslSocketFactoryRequestContextKey}
* property.
*/
public static final String DEFAULT_SSL_SOCKET_FACTORY_REQUEST_CONTEXT_KEY = "com.sun.xml.internal.ws.transport.https.client.SSLSocketFactory";
private String url = "http://localhost:9000/";
private String uid = "OCPP Central System";
private String groupUID;
private String chargePointModel = "SolarNode";
private String chargePointVendor = "SolarNetwork";
private String firmwareVersion;
private MessageSource messageSource;
private ChargeConfigurationDao chargeConfigurationDao;
private OptionalService<IdentityService> identityService;
private OptionalService<SSLService> sslService;
private String sslSocketFactoryRequestContextKey = DEFAULT_SSL_SOCKET_FACTORY_REQUEST_CONTEXT_KEY;
private Scheduler scheduler;
private CentralSystemService service;
private boolean useFromAddress;
private final WSAddressingFromHandler fromHandler = new WSAddressingFromHandler();
private final HMACHandler hmacHandler = new HMACHandler();
private BootNotificationResponse bootNotificationResponse;
private Throwable bootNotificationError;
private SimpleTrigger heartbeatTrigger;
private final Logger log = LoggerFactory.getLogger(getClass());
@Override
public CentralSystemService service() {
CentralSystemService client = getServiceInternal();
postBootNotificationIfNeeded();
configureHeartbeatIfNeeded();
return client;
}
private synchronized void postBootNotificationIfNeeded() {
if ( bootNotificationResponse == null ) {
try {
postBootNotification();
} catch ( RuntimeException e ) {
bootNotificationError = e;
if ( log.isDebugEnabled() ) {
log.debug("Error posting BootNotification message to {}", url, e);
} else {
log.warn("Error posting BootNotification message to {}: {}", url, e.getMessage());
}
}
}
}
private synchronized void configureHeartbeatIfNeeded() {
if ( heartbeatTrigger == null ) {
// we use the heartbeat job to also re-try the initial boot notification if that has failed
configureHeartbeat(30, SimpleTrigger.REPEAT_INDEFINITELY);
}
}
/**
* Initialize the OCPP client. Call this once after all properties
* configured.
*/
public void startup() {
log.info("Starting up OCPP service {}", url);
postBootNotificationIfNeeded();
configureHeartbeatIfNeeded();
}
/**
* Shutdown the OCPP client, releasing any associated resources.
*/
public void shutdown() {
configureHeartbeat(0, 0);
bootNotificationResponse = null;
bootNotificationError = null;
}
private CentralSystemService getServiceInternal() {
CentralSystemService result = service;
if ( result == null ) {
URL wsdl = CentralSystemService.class
.getResource("ocpp_centralsystemservice_1.5_final.wsdl");
QName name = new QName("urn://Ocpp/Cs/2012/06/", "CentralSystemService");
CentralSystemService client = new CentralSystemService_Service(wsdl, name)
.getCentralSystemServiceSoap12(new AddressingFeature());
((BindingProvider) client).getRequestContext().put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY,
this.url);
SSLSocketFactory sslSocketFactory = getSslSocketFactory();
if ( sslSocketFactory != null ) {
log.info("Using custom SSLSocketFactory {} for OCPP CentralSystemService",
sslSocketFactory);
((BindingProvider) client).getRequestContext().put(sslSocketFactoryRequestContextKey,
sslSocketFactory);
}
result = client;
setupFromHandler(client, useFromAddress);
service = client;
}
return result;
}
private SSLSocketFactory getSslSocketFactory() {
SSLService service = (sslService != null ? sslService.service() : null);
return (service != null ? service.getSSLSocketFactory() : null);
}
private void setupFromHandler(final CentralSystemService client, final boolean use) {
if ( client == null ) {
return;
}
BindingProvider bindingProvider = (BindingProvider) client;
boolean modified = false;
@SuppressWarnings("rawtypes")
List<Handler> chain = bindingProvider.getBinding().getHandlerChain();
if ( use ) {
boolean foundFrom = false;
boolean foundHmac = false;
for ( Handler<?> h : chain ) {
if ( h == fromHandler ) {
foundFrom = true;
} else if ( h == hmacHandler ) {
foundHmac = true;
}
}
if ( !foundFrom ) {
chain.add(fromHandler);
modified = true;
}
if ( !foundHmac ) {
chain.add(hmacHandler);
modified = true;
}
} else {
for ( @SuppressWarnings("rawtypes")
Iterator<Handler> itr = chain.iterator(); itr.hasNext(); ) {
Handler<?> h = itr.next();
if ( h == fromHandler || h == hmacHandler ) {
itr.remove();
modified = true;
break;
}
}
}
if ( modified ) {
bindingProvider.getBinding().setHandlerChain(chain);
}
}
/**
* Get the ChargeBoxIdentity value to use. This method returns the
* {@code Principal#getName()} returned by
* {@link IdentityService#getNodePrincipal()}.
*
* @return The node's {@link Principal} name value, or an empty string if
* none available.
*/
@Override
public String chargeBoxIdentity() {
IdentityService ident = (identityService != null ? identityService.service() : null);
if ( ident == null ) {
log.debug("IdentityService not available; cannot get ChargeBoxIdentity value");
return "";
}
Principal nodePrincipal = ident.getNodePrincipal();
if ( nodePrincipal == null ) {
log.debug("Node Principal not available; cannot get ChargeBoxIdentity value");
return "";
}
return nodePrincipal.getName();
}
@Override
public boolean isBootNotificationPosted() {
return (bootNotificationResponse != null);
}
@Override
public boolean postBootNotification() {
IdentityService ident = (identityService != null ? identityService.service() : null);
if ( ident == null ) {
log.debug("IdentityService not available; cannot post BootNotification");
return false;
}
Long nodeId = ident.getNodeId();
if ( nodeId == null ) {
log.debug("Node ID not available; cannot post BootNotification");
return false;
}
CentralSystemService client = getServiceInternal();
if ( client == null ) {
log.debug("CentralSystemService not available; cannot post BootNotification");
return false;
}
synchronized ( this ) {
BootNotificationRequest req = new BootNotificationRequest();
req.setChargePointModel(this.chargePointModel);
req.setChargePointVendor(this.chargePointVendor);
req.setChargePointSerialNumber(nodeId.toString());
req.setFirmwareVersion(this.firmwareVersion);
BootNotificationResponse res = client.bootNotification(req, chargeBoxIdentity());
if ( res == null ) {
log.warn("No response from BootNotificationRequest");
return false;
}
setBootNotificationResponse(res);
}
return true;
}
private void setBootNotificationResponse(BootNotificationResponse response) {
if ( response == bootNotificationResponse ) {
return;
}
if ( response == null ) {
bootNotificationResponse = null;
return;
}
log.info("OCPP BootNotification reply: {} @ {}; heartbeat {}s", response.getStatus(),
response.getCurrentTime(), response.getHeartbeatInterval());
if ( RegistrationStatus.ACCEPTED == response.getStatus() && configureHeartbeat(
response.getHeartbeatInterval(), SimpleTrigger.REPEAT_INDEFINITELY) ) {
bootNotificationResponse = response;
} else {
bootNotificationResponse = response;
}
}
private synchronized boolean configureHeartbeat(final int heartbeatInterval, final int repeatCount) {
Scheduler sched = scheduler;
if ( sched == null ) {
log.warn("No scheduler avaialable, cannot schedule heartbeat job");
return false;
}
final JobKey jobKey = new JobKey(HEARTBEAT_JOB_NAME, SCHEDULER_GROUP);
final long repeatInterval = heartbeatInterval * 1000L;
SimpleTrigger trigger = heartbeatTrigger;
if ( trigger != null ) {
// check if heartbeatInterval actually changed
if ( trigger.getRepeatInterval() == repeatInterval
&& trigger.getRepeatCount() == repeatCount ) {
log.debug("Heartbeat interval unchanged at {}s", heartbeatInterval);
return true;
}
// trigger has changed!
if ( heartbeatInterval == 0 ) {
try {
sched.unscheduleJob(trigger.getKey());
} catch ( SchedulerException e ) {
log.error("Error unscheduling OCPP heartbeat job", e);
} finally {
heartbeatTrigger = null;
}
} else {
trigger = TriggerBuilder.newTrigger().withIdentity(trigger.getKey()).forJob(jobKey)
.usingJobData(new JobDataMap(Collections.singletonMap("service", this)))
.withSchedule(repeatCount < 1
? SimpleScheduleBuilder.repeatSecondlyForever(heartbeatInterval)
: SimpleScheduleBuilder.repeatSecondlyForTotalCount(repeatCount,
heartbeatInterval))
.build();
try {
sched.rescheduleJob(trigger.getKey(), trigger);
} catch ( SchedulerException e ) {
log.error("Error rescheduling OCPP heartbeat job", e);
} finally {
heartbeatTrigger = trigger;
}
}
return true;
} else if ( heartbeatInterval < 1 ) {
// nothing to do
return true;
}
synchronized ( sched ) {
try {
JobDetail jobDetail = sched.getJobDetail(jobKey);
if ( jobDetail == null ) {
jobDetail = JobBuilder.newJob(HeartbeatJob.class).withIdentity(jobKey).storeDurably()
.build();
sched.addJob(jobDetail, true);
}
final TriggerKey triggerKey = new TriggerKey(this.url, SCHEDULER_GROUP);
trigger = TriggerBuilder.newTrigger().withIdentity(triggerKey).forJob(jobKey)
.startAt(new Date(System.currentTimeMillis() + repeatInterval))
.usingJobData(new JobDataMap(Collections.singletonMap("service", this)))
.withSchedule((repeatCount < 1
? SimpleScheduleBuilder.repeatSecondlyForever(heartbeatInterval)
: SimpleScheduleBuilder.repeatSecondlyForTotalCount(repeatCount,
heartbeatInterval))
.withMisfireHandlingInstructionNextWithExistingCount())
.build();
sched.scheduleJob(trigger);
heartbeatTrigger = trigger;
return true;
} catch ( Exception e ) {
log.error("Error scheduling OCPP heartbeat job", e);
return false;
}
}
}
@Override
public void handleEvent(Event event) {
final String topic = event.getTopic();
try {
if ( topic.equals(ChargeConfigurationDao.EVENT_TOPIC_CHARGE_CONFIGURATION_UPDATED) ) {
handleChargeConfigurationUpdated();
}
} catch ( RuntimeException e ) {
log.error("Error handling event {}", topic, e);
}
}
private void handleChargeConfigurationUpdated() {
ChargeConfiguration config = chargeConfigurationDao.getChargeConfiguration();
if ( config.getHeartBeatInterval() >= 0 ) {
configureHeartbeat(config.getHeartBeatInterval(), SimpleTrigger.REPEAT_INDEFINITELY);
}
}
// SettingSpecifierProvider
@Override
public String getSettingUID() {
return "net.solarnetwork.node.ocpp.central";
}
@Override
public String getDisplayName() {
return "OCPP Central System";
}
@Override
public List<SettingSpecifier> getSettingSpecifiers() {
ConfigurableCentralSystemServiceFactory defaults = new ConfigurableCentralSystemServiceFactory();
List<SettingSpecifier> results = new ArrayList<SettingSpecifier>(3);
results.add(new BasicTitleSettingSpecifier("info", getInfoMessage(), true));
results.add(new BasicTextFieldSettingSpecifier("uid", String.valueOf(defaults.uid)));
results.add(new BasicTextFieldSettingSpecifier("groupUID", defaults.groupUID));
results.add(new BasicTextFieldSettingSpecifier("url", defaults.url));
results.add(new BasicTextFieldSettingSpecifier("chargePointModel", defaults.chargePointModel));
results.add(new BasicTextFieldSettingSpecifier("chargePointVendor", defaults.chargePointVendor));
results.add(new BasicToggleSettingSpecifier("useFromAddress", defaults.useFromAddress));
results.add(new BasicTextFieldSettingSpecifier("fromHandler.fromURL",
defaults.fromHandler.getFromURL()));
results.add(new BasicTextFieldSettingSpecifier("fromHandler.dynamicFromPath",
defaults.fromHandler.getDynamicFromPath()));
results.add(new BasicTextFieldSettingSpecifier("fromHandler.networkInterfaceName",
defaults.fromHandler.getNetworkInterfaceName()));
results.add(new BasicToggleSettingSpecifier("fromHandler.preferIPv4Address",
defaults.fromHandler.isPreferIPv4Address()));
results.add(
new BasicTextFieldSettingSpecifier("hmacHandler.secret", HMACHandler.DEFAULT_SECRET));
results.add(new BasicTextFieldSettingSpecifier("hmacHandler.maximumTimeSkew",
String.valueOf(hmacHandler.getMaximumTimeSkew())));
return results;
}
private String getInfoMessage() {
StringBuilder buf = new StringBuilder();
BootNotificationResponse bootResponse = bootNotificationResponse;
Throwable bootError = bootNotificationError;
if ( bootResponse != null ) {
if ( bootResponse.getStatus() == RegistrationStatus.ACCEPTED ) {
buf.append(messageSource.getMessage("status.accepted",
new Object[] {
(bootResponse.getCurrentTime() != null
? bootResponse.getCurrentTime().toString() : "N/A"),
bootResponse.getHeartbeatInterval() / 60 },
Locale.getDefault()));
} else {
buf.append(messageSource.getMessage("status.rejected",
new Object[] { chargeBoxIdentity(), bootResponse.getCurrentTime() },
Locale.getDefault()));
}
} else if ( bootError != null ) {
while ( bootError.getCause() != null ) {
bootError = bootError.getCause();
}
buf.append(messageSource.getMessage("status.error", new Object[] { bootError.getMessage() },
Locale.getDefault()));
}
return (buf.length() > 0 ? buf.toString() : "N/A");
}
@Override
public MessageSource getMessageSource() {
return messageSource;
}
// Accessors
@Override
public String getUID() {
return getUid();
}
public String getUid() {
return uid;
}
public void setUid(String uid) {
this.uid = uid;
}
@Override
public String getGroupUID() {
return groupUID;
}
public void setGroupUID(String groupUID) {
this.groupUID = groupUID;
}
/**
* Get the absolute web service URL to the OCPP central system.
*
* @return The absolute web service URL.
*/
public String getUrl() {
return url;
}
/**
* Set the absolute web service URL to the OCPP central system.
*
* @param url
* The absolute web service URL.
*/
public void setUrl(String url) {
if ( url == null || !url.equals(this.url) ) {
final boolean restart = this.service != null;
if ( restart ) {
shutdown();
this.service = null;
}
this.url = url;
if ( restart ) {
startup();
}
}
}
/**
* Set the {@link MessageSource} to use for settings.
*
* @param messageSource
* The message source to use.
*/
public void setMessageSource(MessageSource messageSource) {
this.messageSource = messageSource;
}
/**
* Set the {@link IdentityService} to use for identifying the SolarNode to
* the OCPP server.
*
* @param identityService
* The {@link IdentityService} to use.
*/
public void setIdentityService(OptionalService<IdentityService> identityService) {
this.identityService = identityService;
}
/**
* Set the model name.
*
* @param chargePointModel
* The model name to set.
*/
public void setChargePointModel(String chargePointModel) {
this.chargePointModel = chargePointModel;
}
/**
* Set the vendor name.
*
* @param chargePointVendor
* The vendor name to set.
*/
public void setChargePointVendor(String chargePointVendor) {
this.chargePointVendor = chargePointVendor;
}
/**
* Set the version.
*
* @param firmwareVersion
* The version to set.
*/
public void setFirmwareVersion(String chargePointVersion) {
this.firmwareVersion = chargePointVersion;
}
/**
* Set the version as an OSGi version. This will pass
* {@link Version#toString()} to {@link #setFirmwareVersion(String)}.
*
* @param version
* The version to set.
*/
public void setVersion(Version version) {
setFirmwareVersion(version == null ? "" : version.toString());
}
/**
* Set the Scheduler to use for the {@link HeartbeatJob}.
*
* @param scheduler
* The scheduler to use.
*/
public void setScheduler(Scheduler scheduler) {
this.scheduler = scheduler;
}
/**
* Get the flag to add WS-Addressing {@code From} headers into outbound OCPP
* messages.
*
* @return Boolean
*/
public boolean isUseFromAddress() {
return useFromAddress;
}
/**
* Set the flag to add WS-Addressing {@code From} headers into outbound OCPP
* messages. If set to <em>true</em> then the {@code fromAddress} property
* will be used as the address value. If that property is not configured, a
* default HTTP URL will be constructed out of the system's local IP address
* with a default path of {@code /ocpp} added.
*
* @param useFromAddress
* Boolean
*/
public void setUseFromAddress(boolean useFromAddress) {
this.useFromAddress = useFromAddress;
setupFromHandler(getServiceInternal(), useFromAddress);
}
public WSAddressingFromHandler getFromHandler() {
return fromHandler;
}
public HMACHandler getHmacHandler() {
return hmacHandler;
}
public void setChargeConfigurationDao(ChargeConfigurationDao chargeConfigurationDao) {
this.chargeConfigurationDao = chargeConfigurationDao;
}
/**
* Set a {@link SSLService} to use. If configured then the
* {@code SSLSocketFactory} returned by
* {@link SSLService#getSSLSocketFactory()} will be configured for use by
* the returned {@code CentralSystemService}.
*
* @param sslService
* the sslService to set
* @since 1.2
*/
public void setSslService(OptionalService<SSLService> sslService) {
this.sslService = sslService;
}
/**
* Set the {@link BindingProvider#getRequestContext()} key to use for the
* {@code SSLSocketFactory} obtained from the configured {@code SSLService}.
*
* This defaults to {@link #DEFAULT_SSL_SOCKET_FACTORY_REQUEST_CONTEXT_KEY}
* which is good for the JAX-WS included with the JRE. If using a different
* JAX-WS provider, this key most likely would need to change.
*
* @param key
* the sslSocketFactoryBindingProviderKey to set (must not be
* {@code null})
* @since 1.2
*/
public void setSslSocketFactoryRequestContextKey(String key) {
assert key != null;
this.sslSocketFactoryRequestContextKey = key;
}
}