/* ================================================================== * EnaSolarXMLDatumDataSource.java - Oct 2, 2011 8:50:13 PM * * Copyright 2007-2011 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.power.enasolar.ws; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.xml.xpath.XPathExpression; import net.solarnetwork.domain.GeneralDatumMetadata; import net.solarnetwork.node.DatumDataSource; import net.solarnetwork.node.dao.SettingDao; import net.solarnetwork.node.domain.GeneralNodePVEnergyDatum; import net.solarnetwork.node.domain.PVEnergyDatum; 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.support.XmlServiceSupport; import net.solarnetwork.util.StringUtils; import org.springframework.context.MessageSource; /** * Web service based support for EnaSolar inverters. * * This service can read from two different XML services provided by EnaSolar * inverters. First is the {@bold deviceinfo.xml} form. This expects to * access a URL that returns XML in the following form: * * <pre> * <info time="16180F57"> * <data key="vendor" value="EnaSolar" /> * <data key="model" value="2" /> * <data key="acOutputVolts" value="241.1" /> * <data key="pvVolts" value="304.3" /> * <data key="pvPower" value="0.681" /> * <data key="acPower" value="0.628" /> * <data key="kWattHoursToday" value="17.94" /> * <data key="decaWattHoursTotal" value="0000167A" /> * </info> * </pre> * * The second is the {@bold data.xml} and {@bold meters.xml} form. * The first URL should return XML in the following form: * * <pre> * <response> * <EnergyToday>0000</EnergyToday> * <EnergyYesterday>01ED</EnergyYesterday> * <DU>0000</DU> * <EnergyLifetime>000BA446</EnergyLifetime> * <HoursExportedToday>0</HoursExportedToday> * <HoursExportedYesterday>493</HoursExportedYesterday> * <HoursExportedLifetime>000A032E</HoursExportedLifetime> * <DaysProducing>03F5</DaysProducing> * </response> * </pre> * * and the second URL in the following form: * * <pre> * <response> * <OutputPower>0</OutputPower> * <InputVoltage>0</InputVoltage> * <OutputVoltage>0</OutputVoltage> * </response> * </pre> * * <p> * The configurable properties of this class are: * </p> * * <dl class="class-properties"> * <dt>urls</dt> * <dd>A comma-delimited list of URLs for accessing the XML data from (more than * one in case the {@code data.xml} and {@code meters.xml} URLs are used).</dd> * * <dt>sourceId</dt> * <dd>The source ID value to assign to the collected data.</dd> * * <dt>xpathMapping</dt> * <dd>A mapping of PowerDatum JavaBean property names to corresponding XPath * expressions for extracting the data from the XML response. This property can * also be configured via {@link #setXpathMap(Map)} using String values, which * is useful when using Spring. Defaults to a sensible value, so this should * only be configured in special cases.</dd> * * <dt>groupUID</dt> * <dd>The service group ID to use.</dd> * * <dt>messageSource</dt> * <dd>The {@link MessageSource} to use with {@link SettingSpecifierProvider}.</dd> * </dl> * * @author matt * @version 1.3 */ public class EnaSolarXMLDatumDataSource extends XmlServiceSupport implements DatumDataSource<GeneralNodePVEnergyDatum>, SettingSpecifierProvider { /** The {@link SettingDao} key for a Long counter of 0 watt readings. */ public static final String SETTING_ZERO_WATT_COUNT = "EnaSolarXMLDatumDataSource:0W"; /** The maximum number of consecutive zero-watt readings to return. */ public static final long ZERO_WATT_THRESHOLD = 10; /** The default value for the {@code urlList} property. */ public static final String DEFAULT_URL_LIST = "http://enasolar-gt/data.xml,http://enasolar-gt/meters.xml"; private static final Pattern DATA_VALUE_XPATH_NAME = Pattern.compile("key='(\\w+)'"); private String[] urls; private String sourceId; private Map<String, XPathExpression> xpathMapping; private Map<String, String> xpathMap; private MessageSource messageSource; private long sampleCacheMs = 5000; private EnaSolarPowerDatum sample; private Throwable sampleException; private final Map<String, Long> validationCache = new HashMap<String, Long>(4); private EnaSolarPowerDatum getCurrentSample() { EnaSolarPowerDatum datum; if ( isCachedSampleExpired() ) { datum = new EnaSolarPowerDatum(); datum.setCreated(new Date()); datum.setSourceId(sourceId); sampleException = null; for ( String url : urls ) { try { webFormGetForBean(null, datum, url, null, xpathMapping); } catch ( RuntimeException e ) { Throwable root = e; while ( root.getCause() != null ) { root = root.getCause(); } sampleException = root; if ( root instanceof IOException ) { // turn this into a WARN only log.warn("Error communicating with EnaSolar inverter at {}: {}", url, e.getMessage()); // with a stacktrace in DEBUG log.debug("IOException communicating with EnaSolar inverter at {}", url, e); return null; } else { throw e; } } } datum = validateDatum(datum); if ( datum != null ) { sample = datum; addEnergyDatumSourceMetadata(datum); postDatumCapturedEvent(datum, PVEnergyDatum.class); } } else { datum = sample; } return datum; } private boolean isCachedSampleExpired() { EnaSolarPowerDatum snap = sample; if ( snap == null || sample.getCreated() == null ) { return true; } final long lastReadDiff = System.currentTimeMillis() - sample.getCreated().getTime(); if ( lastReadDiff > sampleCacheMs ) { return true; } if ( validateDatum(snap) == null ) { return true; // not valid } return false; } @Override public String toString() { return "Enasolar" + (sourceId == null ? "" : "-" + sourceId); } @Override public void init() { super.init(); if ( xpathMapping == null ) { setXpathMap(defaultXpathMap()); } if ( urls == null ) { setUrlList(DEFAULT_URL_LIST); } } private static Map<String, String> defaultXpathMap() { Map<String, String> result = new LinkedHashMap<String, String>(10); result.put("outputPower", "//OutputPower"); result.put("voltage", "//OutputVoltage"); result.put("DCVoltage", "//InputVoltage"); result.put("energyLifetime", "//EnergyLifetime"); return result; } @Override public Class<? extends GeneralNodePVEnergyDatum> getDatumType() { return EnaSolarPowerDatum.class; } @Override public GeneralNodePVEnergyDatum readCurrentDatum() { return getCurrentSample(); } private Long zeroWattCount() { return (validationCache.containsKey(SETTING_ZERO_WATT_COUNT) ? validationCache .get(SETTING_ZERO_WATT_COUNT) : 0L); } private Long lastKnownValue() { EnaSolarPowerDatum snap = sample; Long result = null; if ( snap != null ) { result = snap.getWattHourReading(); } return (result == null ? 0L : result); } private boolean isSampleOnSameDay(final Date sampleDate) { final Date lastKnownDate = (sample != null ? sample.getCreated() : null); if ( sampleDate == null || lastKnownDate == null ) { return false; } final Calendar sampleCal = Calendar.getInstance(); sampleCal.setTime(sampleDate); final Calendar lastKnownCal = Calendar.getInstance(); lastKnownCal.setTime(lastKnownDate); return (sampleCal.get(Calendar.DAY_OF_YEAR) == lastKnownCal.get(Calendar.DAY_OF_YEAR) && sampleCal .get(Calendar.YEAR) == lastKnownCal.get(Calendar.YEAR)); } private EnaSolarPowerDatum validateDatum(EnaSolarPowerDatum datum) { final Long currValue = datum.getWattHourReading(); final Long zeroWattCount = zeroWattCount(); final Long lastKnownValue = lastKnownValue(); final boolean dailyResettingWh = datum.isUsingDailyResettingTotal(); // we've seen values reported less than last known value after // a power outage (i.e. after inverter turns off, then back on) // on single day, so we verify that current decaWattHoursTotal is not less // than last known decaWattHoursTotal value if ( currValue != null && currValue.longValue() < lastKnownValue.longValue() && (dailyResettingWh == false || (dailyResettingWh && zeroWattCount < 1L)) ) { log.warn( "Inverter [{}] reported value {} -- less than last known value {}. Discarding this datum.", sourceId, currValue, lastKnownValue); datum = null; } else if ( currValue != null && currValue.longValue() < 1L && dailyResettingWh && isSampleOnSameDay(datum.getCreated()) == false ) { log.debug("Resetting last known sample for new day zero Wh"); sample = datum; datum = null; } else if ( datum.getWatts() == null || datum.getWatts() < 1 ) { final Long newCount = (zeroWattCount.longValue() + 1); if ( zeroWattCount >= ZERO_WATT_THRESHOLD ) { log.debug("Skipping zero-watt reading #{}", zeroWattCount); datum = null; } validationCache.put(SETTING_ZERO_WATT_COUNT, newCount); } else if ( zeroWattCount > 0 ) { // reset zero-watt counter log.debug("Resetting zero-watt reading count from non-zero reading"); validationCache.remove(SETTING_ZERO_WATT_COUNT); } // everything checks out return datum; } private void addEnergyDatumSourceMetadata(EnaSolarPowerDatum d) { if ( d == null ) { return; } // associate consumption/generation tags with this source GeneralDatumMetadata sourceMeta = new GeneralDatumMetadata(); sourceMeta.addTag(net.solarnetwork.node.domain.EnergyDatum.TAG_GENERATION); addSourceMetadata(d.getSourceId(), sourceMeta); } @Override public String getUID() { return getSourceId(); } /** * Set the XPath mapping using String values. * * @param xpathMap * the string XPath mapping values */ public void setXpathMap(Map<String, String> xpathMap) { this.xpathMap = xpathMap; setXpathMapping(getXPathExpressionMap(xpathMap)); } /** * Get the XML data mapping as a delimited string. * * @return delimited string of XML mappings */ public String getDataMapping() { StringBuilder buf = new StringBuilder(); if ( xpathMap != null ) { for ( Map.Entry<String, String> me : xpathMap.entrySet() ) { if ( buf.length() > 0 ) { buf.append(", "); } buf.append(me.getKey()).append('='); Matcher m = DATA_VALUE_XPATH_NAME.matcher(me.getValue()); if ( m.find() ) { buf.append(m.group(1)); } else { buf.append(me.getValue().toString()); } } } return buf.toString(); } /** * Set the XML data mapping. This supports setting simple * {@code deviceinfo.xml} key names or, if the value contains a {@code /} * character, direct XPath values. * * * @param mapping * comma-delimited equal-delimited key value pair list */ public void setDataMapping(String mapping) { if ( mapping == null || mapping.length() < 1 ) { return; } String[] pairs = mapping.split("\\s*,\\s*"); Map<String, String> map = new LinkedHashMap<String, String>(); for ( String pair : pairs ) { String[] kv = pair.split("\\s*=\\s*"); if ( kv == null || kv.length != 2 ) { continue; } if ( kv[1].contains("/") ) { map.put(kv[0], kv[1]); } else { map.put(kv[0], "//data[@key='" + kv[1] + "']/@value"); } } setXpathMap(map); } @Override public String getSettingUID() { return "net.solarnetwork.node.power.enasolar"; } @Override public String getDisplayName() { return "EnaSolar web service data source"; } @Override public List<SettingSpecifier> getSettingSpecifiers() { EnaSolarXMLDatumDataSource defaults = new EnaSolarXMLDatumDataSource(); defaults.init(); List<SettingSpecifier> results = new ArrayList<SettingSpecifier>(10); results.add(new BasicTitleSettingSpecifier("info", getInfoMessage(), true)); results.add(new BasicTextFieldSettingSpecifier("urlList", defaults.getUrlList())); results.add(new BasicTextFieldSettingSpecifier("sourceId", "")); results.add(new BasicTextFieldSettingSpecifier("groupUID", null)); results.add(new BasicTextFieldSettingSpecifier("dataMapping", defaults.getDataMapping())); results.add(new BasicTextFieldSettingSpecifier("sampleCacheMs", String .valueOf(defaults.sampleCacheMs))); return results; } /** * Get an informational status message. * * @return A status message. */ public String getInfoMessage() { EnaSolarPowerDatum snap = null; try { snap = getCurrentSample(); } catch ( Exception e ) { // we must ignore exceptions here } StringBuilder buf = new StringBuilder(); Throwable t = sampleException; if ( t != null ) { buf.append("Error communicating with EnaSolar inverter: ").append(t.getMessage()); } if ( snap != null ) { if ( buf.length() > 0 ) { buf.append("; "); } buf.append(snap.getWatts()).append(" W; "); buf.append(snap.getWattHourReading()).append(" Wh; sample created "); buf.append(String.format("%tc", snap.getCreated())); } return (buf.length() < 1 ? "N/A" : buf.toString()); } @Override public MessageSource getMessageSource() { return messageSource; } public String getUrl() { return (urls == null || urls.length < 1 ? null : urls[0]); } public void setUrl(String url) { if ( urls == null || urls.length < 1 ) { urls = new String[] { url }; } else { urls[0] = url; } } public String[] getUrls() { return urls; } public void setUrls(String[] urls) { this.urls = urls; } /** * Get the URL list. * * @return list of URLs */ public String getUrlList() { return StringUtils.delimitedStringFromCollection(Arrays.asList(this.urls), ","); } /** * Set the URL list as a comma-delimited string. * * @param list * the comma-delimited list of URLs */ public void setUrlList(String list) { Set<String> set = StringUtils.delimitedStringToSet(list, ","); setUrls(set.toArray(new String[set.size()])); } public void setMessageSource(MessageSource messageSource) { this.messageSource = messageSource; } public Map<String, XPathExpression> getXpathMapping() { return xpathMapping; } public void setXpathMapping(Map<String, XPathExpression> xpathMapping) { this.xpathMapping = xpathMapping; } public String getSourceId() { return sourceId; } public void setSourceId(String sourceId) { this.sourceId = sourceId; validationCache.clear(); } public void setSampleCacheMs(long sampleCacheMs) { this.sampleCacheMs = sampleCacheMs; } }