/* =================================================================== * SMASunnyNetPowerDatumDataSource.java * * Created Aug 19, 2009 1:21:11 PM * * Copyright (c) 2009 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.impl.sma.sunnynet; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import net.solarnetwork.node.ConversationalDataCollector; import net.solarnetwork.node.DataCollectorFactory; import net.solarnetwork.node.DatumDataSource; import net.solarnetwork.node.dao.SettingDao; import net.solarnetwork.node.hw.sma.SMAInverterDataSourceSupport; import net.solarnetwork.node.hw.sma.sunnynet.SmaChannel; import net.solarnetwork.node.hw.sma.sunnynet.SmaChannelParam; import net.solarnetwork.node.hw.sma.sunnynet.SmaCommand; import net.solarnetwork.node.hw.sma.sunnynet.SmaControl; import net.solarnetwork.node.hw.sma.sunnynet.SmaPacket; import net.solarnetwork.node.hw.sma.sunnynet.SmaUserDataField; import net.solarnetwork.node.hw.sma.sunnynet.SmaUtils; import net.solarnetwork.node.power.PowerDatum; 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.SerialPortBeanParameters; import net.solarnetwork.node.util.PrefixedMessageSource; import net.solarnetwork.util.DynamicServiceTracker; import net.solarnetwork.util.StringUtils; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.PropertyAccessor; import org.springframework.beans.PropertyAccessorFactory; import org.springframework.context.MessageSource; import org.springframework.context.support.ResourceBundleMessageSource; /** * Implementation of {@link GenerationDataSource} for SMA controllers. * * <p> * In limited testing, the following * {@code SerialPortConversationalDataCollectorFactory} property values work * well for communicating with SMA over a RS-232 serial connection: * </p> * * <dl> * <dt>serialPort</dt> * <dd>/dev/ttyS0 (this will vary depending on system)</dd> * * <dt>baud</dt> * <dd>1200</dd> * * <dt>rts</dt> * <dd>false</dd> * * <dt>dtr</dt> * <dd>false</dt> * * <dt>receiveThreshold</dt> * <dd>-1</dd> * * <dt>receiveTimeout</dt> * <dd>2000</dd> * * <dt>maxWait</dt> * <dd>60000</dd> * </dl> * * <p> * This class is not generally not thread-safe. Only one thread should execute * {@link #readCurrentDatum()} at a time. * </p> * * <p> * The configurable properties of this class are: * </p> * * <dl class="class-properties"> * <dt>dataCollectorFactory</dt> * <dd>The factory for creating {@link ConversationalDataCollector} instances * with. {@link GenericObjectFactory#getObject()} will be called on each * invocation of {@link #readCurrentPowerDatum()}.</dd> * * <dt>synOnlineWaitMs</dt> * <dd>Number of milliseconds to wait after issuing the SynOnline command. A * wait seems to be necessary otherwise the first data request fails. Defaults * to {@link #DEFAULT_SYN_ONLINE_WAIT_MS}.</dd> * * <dt>channelNamesToResetDaily</dt> * <dd>If configured, a set of channels to reset each day to a zero value. This * is useful for resetting accumulative counter values, such as E-Total, on a * daily basis for tracking the total kWh generated each day. Requires the * {@code settingDao} property to also be configured.</dd> * * <dt>channelNamesToOffsetDaily</dt> * <dd>If configured, a set of channels to treat as ever-accumulating numbers * that should be treated as daily-resetting values. This can be used, for * example, to calculate a "kWh generated today" value from a "E-Total" channel * that is not reset by the inverter itself. When reading values on the start of * a new day, the value of that channel is persisted so subsequent readings on * the same day can be calculated as an offset from that initial value. Requires * the {@code settingDao} property to also be configured.</dd> * * <dt>settingDao</dt> * <dd>The {@link SettingDao} to use, required by the * {@code channelNamesToResetDaily} property.</dd> * </dl> * * <p> * Based on code from Gray Watson's sma.pl script, copyright included here: * </p> * * <pre> * # Copyright 2004 by Gray Watson * # * # Permission to use, copy, modify, and distribute this software for * # any purpose and without fee is hereby granted, provided that the * # above copyright notice and this permission notice appear in all * # copies, and that the name of Gray Watson not be used in advertising * # or publicity pertaining to distribution of the document or software * # without specific, written prior permission. * # * # Gray Watson makes no representations about the suitability of the * # software described herein for any purpose. It is provided "as is" * # without express or implied warranty. * # * # The author may be contacted via http://256.com/gray/ * </pre> * * @author matt * @version 1.1 */ public class SMASunnyNetPowerDatumDataSource extends SMAInverterDataSourceSupport implements DatumDataSource<PowerDatum>, ConversationalDataCollector.Moderator<PowerDatum>, SettingSpecifierProvider { /** The PV current channel name. */ public static final String CHANNEL_NAME_PV_AMPS = "Ipv"; /** The PV voltage channel name. */ public static final String CHANNEL_NAME_PV_VOLTS = "Vpv"; /** The accumulative kWh channel name. */ public static final String CHANNEL_NAME_KWH = "E-Total"; /** * Default value for the {@code channelNamesToMonitor} property. * * <p> * Contains the PV voltage, PV current, and kWh channels. * </p> */ public static final Set<String> DEFAULT_CHANNEL_NAMES_TO_MONITOR = Collections .unmodifiableSet(new LinkedHashSet<String>(Arrays.asList(CHANNEL_NAME_PV_AMPS, CHANNEL_NAME_PV_VOLTS, CHANNEL_NAME_KWH))); /** The default value for the {@code synOnlineWaitMs} property. */ public static final long DEFAULT_SYN_ONLINE_WAIT_MS = 5000; private static final String DEFAULT_SERIAL_PORT = "/dev/ttyS0"; private static final SerialPortBeanParameters DEFAULT_SERIAL_PARAMS = new SerialPortBeanParameters(); static { DEFAULT_SERIAL_PARAMS.setBaud(1200); DEFAULT_SERIAL_PARAMS.setDataBits(8); DEFAULT_SERIAL_PARAMS.setStopBits(1); DEFAULT_SERIAL_PARAMS.setParity(0); DEFAULT_SERIAL_PARAMS.setDtrFlag(0); DEFAULT_SERIAL_PARAMS.setRtsFlag(0); DEFAULT_SERIAL_PARAMS.setReceiveThreshold(-1); DEFAULT_SERIAL_PARAMS.setReceiveTimeout(2000); DEFAULT_SERIAL_PARAMS.setMaxWait(65000); } private static final Object MONITOR = new Object(); private static MessageSource MESSAGE_SOURCE; private String pvVoltsChannelName = CHANNEL_NAME_PV_VOLTS; private String pvAmpsChannelName = CHANNEL_NAME_PV_AMPS; private String kWhChannelName = CHANNEL_NAME_KWH; private long synOnlineWaitMs = DEFAULT_SYN_ONLINE_WAIT_MS; private String sourceId = "Main"; private String groupUID; private DynamicServiceTracker<DataCollectorFactory<SerialPortBeanParameters>> dataCollectorFactory; private SerialPortBeanParameters serialParams = getDefaultSerialParameters(); private int smaAddress = -1; private Map<String, SmaChannel> channelMap = null; private final Logger log = LoggerFactory.getLogger(getClass()); public SMASunnyNetPowerDatumDataSource() { super(); setChannelNamesToMonitor(DEFAULT_CHANNEL_NAMES_TO_MONITOR); } /** * Get the default serial parameters used for SMA inverters. * * @return */ public static final SerialPortBeanParameters getDefaultSerialParameters() { return (SerialPortBeanParameters) DEFAULT_SERIAL_PARAMS.clone(); } @Override public Class<? extends PowerDatum> getDatumType() { return PowerDatum.class; } private ConversationalDataCollector getDataCollectorInstance() { final DataCollectorFactory<SerialPortBeanParameters> df = getDataCollectorFactory().service(); if ( df == null ) { return null; } return df.getConversationalDataCollectorInstance(getSerialParams()); } private void setupChannelNamesToMonitor() { Set<String> s = new LinkedHashSet<String>(3); s.add(getPvVoltsChannelName()); s.add(getPvAmpsChannelName()); s.add(getkWhChannelName()); if ( !s.equals(this.getChannelNamesToMonitor()) ) { setChannelNamesToMonitor(s); this.channelMap = null; } } @Override public PowerDatum readCurrentDatum() { ConversationalDataCollector dataCollector = null; try { dataCollector = getDataCollectorInstance(); if ( dataCollector != null ) { return dataCollector.collectData(this); } } finally { if ( dataCollector != null ) { dataCollector.stopCollecting(); } } return null; } @Override public PowerDatum conductConversation(ConversationalDataCollector dataCollector) { SmaPacket req = null; SmaPacket resp = null; if ( this.smaAddress < 0 || this.channelMap == null ) { // Issue NetStart command to find SMA address req = writeCommand(dataCollector, SmaCommand.NetStart, 0, 0, SmaControl.RequestGroup, SmaPacket.EMPTY_DATA); resp = decodeResponse(dataCollector, req); if ( log.isTraceEnabled() ) { log.trace("Got decoded NetStart response: " + resp); } if ( !resp.isValid() ) { log.warn("Invalid response to NetStart command, cannot continue: " + resp); return null; } // TODO handle multiple device responses, for now we only accept one // Issue GetChannelInfo command, to get full list of available channels // This returns a lot of data... so we just do it once and cache the // results for subsequent use this.smaAddress = resp.getSrcAddress(); req = writeCommand(dataCollector, SmaCommand.GetChannelInfo, this.smaAddress, 0, SmaControl.RequestSingle, SmaPacket.EMPTY_DATA); resp = decodeResponse(dataCollector, req); if ( !resp.isValid() ) { log.warn("Invalid response to GetChannelInfo command, cannot continue: " + resp); return null; } Map<String, SmaChannel> channels = getSmaChannelMap(resp); if ( log.isTraceEnabled() ) { log.trace("Got decoded GetChannelInfo response: " + resp + ", with " + channels.size() + " channels decoded"); } this.channelMap = channels; } // Issue SynOnline command int pollTime = (int) Math.ceil(System.currentTimeMillis() / 1000.0); req = writeProclamation(dataCollector, SmaCommand.SynOnline, 0, 0, SmaControl.RequestGroup, SmaUtils.littleEndianBytes(pollTime)); // pause for a few secs, as first channel may not respond otherwise try { Thread.sleep(this.synOnlineWaitMs); } catch ( InterruptedException e ) { // ignore this one } PowerDatum datum = new PowerDatum(); datum.setSourceId(this.sourceId); // Issue GetData command for each channel we're interested in getNumericDataValue(dataCollector, this.pvVoltsChannelName, "pvVolts", datum); getNumericDataValue(dataCollector, this.pvAmpsChannelName, "pvAmps", datum); getNumericDataValue(dataCollector, this.kWhChannelName, "KWattHoursToday", datum); return datum; } /** * Issue a GetData command for a specific channel that returns a numeric * value and set that value onto a PowerDatum instance. * * @param dataCollector * the ConversationalDataCollector to collect the data from * @param channelName * the name of the channel to read * @param beanProperty * the PowerDatum bean property to set with the numeric data value * @param datum * the datum to update * @param newDay * flag if today is considered a "new day" for purposes of calling * {@link #handleDailyChannelOffset(String, Number, boolean)} */ private void getNumericDataValue(ConversationalDataCollector dataCollector, String channelName, String beanProperty, PowerDatum datum) { if ( this.channelMap.containsKey(channelName) ) { SmaChannel channel = this.channelMap.get(channelName); SmaPacket resp = issueGetData(dataCollector, channel, this.smaAddress); if ( resp.isValid() ) { Number n = (Number) resp.getUserDataField(SmaUserDataField.Value); if ( n != null ) { PropertyAccessor accessor = PropertyAccessorFactory.forBeanPropertyAccess(datum); Class<?> propType = accessor.getPropertyType(beanProperty); Number value = n; Object unit = channel.getParameterValue(SmaChannelParam.Unit); if ( unit != null && "mA".equals(unit.toString()) ) { value = divide(propType, n, Integer.valueOf(1000)); } Object gain = channel.getParameterValue(SmaChannelParam.Gain); if ( gain instanceof Number ) { value = mult((Number) gain, value); } accessor.setPropertyValue(beanProperty, value); } } else { log.warn("Invalid response to [" + beanProperty + "] GetData command for channel [" + channelName + "]"); } } } private SmaPacket issueGetData(ConversationalDataCollector dataCollector, SmaChannel channel, int address) { if ( log.isTraceEnabled() ) { log.trace("Getting data for channel " + channel); } byte[] data = SmaUtils.encodeGetDataRequestUserData(channel); SmaPacket req = writeCommand(dataCollector, SmaCommand.GetData, address, 0, SmaControl.RequestSingle, data); return decodeResponse(dataCollector, req); } @SuppressWarnings("unchecked") private Map<String, SmaChannel> getSmaChannelMap(SmaPacket resp) { Map<String, SmaChannel> channels = new LinkedHashMap<String, SmaChannel>(); Object o = resp.getUserDataField(SmaUserDataField.Channels); if ( o instanceof List<?> ) { List<SmaChannel> list = (List<SmaChannel>) o; if ( log.isDebugEnabled() ) { log.debug("Available SMA channels:\n{}", StringUtils.delimitedStringFromCollection(list, ",\n")); } for ( SmaChannel channel : list ) { // prune out channels to only those we are interested in if ( !this.getChannelNamesToMonitor().contains(channel.getName()) ) { continue; } channels.put(channel.getName(), channel); } } return channels; } /** * Write an SmaPacket and listen for a response. * * <p> * The returned {@link SmaPacket} can be passed to * {@link #decodeResponse(ConversationalDataCollector, SmaPacket)} to obtain * the response value. * </p> * * @param dataCollector * the data collector to use * @param cmd * the command to write * @param destAddr * the device destination address * @param count * the packet count (usually this will be 0) * @param control * the request control type (usually RequestSingle or RequestGroup) * @param data * the user data to include in the command * @return the command request packet */ private SmaPacket writeCommand(ConversationalDataCollector dataCollector, SmaCommand cmd, int destAddr, int count, SmaControl control, byte[] data) { SmaPacket packet = createRequestPacket(cmd, destAddr, count, control, data); dataCollector.speakAndListen(packet.getPacket()); return packet; } /** * Write an SmaPacket without listening for a response. * * @param dataCollector * the data collector to use * @param cmd * the command to write * @param destAddr * the device destination address * @param count * the packet count (usually this will be 0) * @param control * the request control type (usually RequestGroup) * @param data * the user data to include in the command * @return the command request packet */ private SmaPacket writeProclamation(ConversationalDataCollector dataCollector, SmaCommand cmd, int destAddr, int count, SmaControl control, byte[] data) { SmaPacket packet = createRequestPacket(cmd, destAddr, count, control, data); dataCollector.speak(packet.getPacket()); return packet; } /** * Create a new SmaPacket instance. * * @param cmd * the command to create * @param destAddr * the device destination address * @param count * the packet counter (requests usually use 0) * @param control * the request control type * @param data * the user data to add to the packet * @return the new packet */ private SmaPacket createRequestPacket(SmaCommand cmd, int destAddr, int count, SmaControl control, byte[] data) { SmaPacket packet = new SmaPacket(0, destAddr, count, control, cmd, data); if ( log.isTraceEnabled() ) { log.trace("CRC: " + packet.getCrc()); } if ( log.isDebugEnabled() ) { log.debug("Sending SMA request " + cmd + ": " + String.valueOf(Hex.encodeHex(packet.getPacket()))); } return packet; } /** * Decode a response to a request SmaPacket. * * <p> * This is usually called after * {@link #writeCommand(ConversationalDataCollector, SmaCommand, int, int, SmaControl, byte[])} * to decode the response into a response SmaPacket instance. * </p> * * <p> * The response might consist of many individual packets. This happens when * the first response packet contains a {@code packetCounter} value greater * than 0. In this situation, this method will create new request packets * based on the original request packet passed into this method, and call * {@link ConversationalDataCollector#speakAndListen(byte[])} repeatedly * until the {@code packetCounter} gets to 0. The {@code userData} values * for each response packet will be combined into one byte array and * returned with the final response packet as the {@code userData} value. * </p> * * @param dataCollector * the data collector * @param originalRequest * the original request packet * @return the response packet */ private SmaPacket decodeResponse(ConversationalDataCollector dataCollector, SmaPacket originalRequest) { ByteArrayOutputStream byos = null; SmaPacket curr = null; // the packetCounter in the response is used to say "there are more packets of data coming" // so we loop here, calling getCollectedData() for the first packet and then if more // packets are available we write the original request command again but with the new count while ( curr == null || curr.getPacketCounter() > 0 ) { byte[] data = dataCollector.getCollectedData(); if ( log.isDebugEnabled() ) { log.debug("Got response data: " + String.valueOf(Hex.encodeHex(data))); } curr = new SmaPacket(data); if ( curr.getPacketCounter() > 0 || byos != null ) { // this is a multi-packet response... store userData into BYOS if ( byos == null ) { byos = new ByteArrayOutputStream(); } try { byos.write(curr.getUserData()); } catch ( IOException e ) { // should not get here for BYOS } if ( curr.getPacketCounter() > 0 ) { SmaPacket packet = new SmaPacket(originalRequest.getSrcAddress(), originalRequest.getDestAddress(), curr.getPacketCounter(), originalRequest.getControl(), originalRequest.getCommand(), originalRequest.getUserData()); dataCollector.speakAndListen(packet.getPacket()); } } } if ( byos == null ) { curr.decodeUserDataFields(); return curr; } // this was a multi-packet response... we just replace the final userData value with // the data collected in the BYOS curr.setUserData(byos.toByteArray()); curr.decodeUserDataFields(); return curr; } @Override public String getSettingUID() { return "net.solarnetwork.node.power.sma.sunnynet"; } @Override public String getDisplayName() { return "SMA SunnyNet inverter"; } @Override public MessageSource getMessageSource() { synchronized ( MONITOR ) { if ( MESSAGE_SOURCE == null ) { ResourceBundleMessageSource serial = new ResourceBundleMessageSource(); serial.setBundleClassLoader(SerialPortBeanParameters.class.getClassLoader()); serial.setBasename(SerialPortBeanParameters.class.getName()); PrefixedMessageSource serialSource = new PrefixedMessageSource(); serialSource.setDelegate(serial); serialSource.setPrefix("serialParams."); ResourceBundleMessageSource source = new ResourceBundleMessageSource(); source.setBundleClassLoader(SMASunnyNetPowerDatumDataSource.class.getClassLoader()); source.setBasename(SMASunnyNetPowerDatumDataSource.class.getName()); source.setParentMessageSource(serialSource); MESSAGE_SOURCE = source; } } return MESSAGE_SOURCE; } @Override public List<SettingSpecifier> getSettingSpecifiers() { List<SettingSpecifier> results = new ArrayList<SettingSpecifier>(20); results.add(new BasicTitleSettingSpecifier("address", (smaAddress < 0 ? "N/A" : String .valueOf(smaAddress)), true)); results.add(new BasicTextFieldSettingSpecifier("dataCollectorFactory.propertyFilters['UID']", DEFAULT_SERIAL_PORT)); results.add(new BasicTextFieldSettingSpecifier("sourceId", DEFAULT_SOURCE_ID)); results.add(new BasicTextFieldSettingSpecifier("groupUID", null)); results.add(new BasicTextFieldSettingSpecifier("pvVoltsChannelName", CHANNEL_NAME_PV_VOLTS)); results.add(new BasicTextFieldSettingSpecifier("pvAmpsChannelName", CHANNEL_NAME_PV_AMPS)); results.add(new BasicTextFieldSettingSpecifier("kWhChannelName", CHANNEL_NAME_KWH)); results.add(new BasicTextFieldSettingSpecifier("synOnlineWaitMs", String .valueOf(DEFAULT_SYN_ONLINE_WAIT_MS))); results.addAll(SerialPortBeanParameters.getDefaultSettingSpecifiers( SMASunnyNetPowerDatumDataSource.getDefaultSerialParameters(), "serialParams.")); return results; } public long getSynOnlineWaitMs() { return synOnlineWaitMs; } public void setSynOnlineWaitMs(long synOnlineWaitMs) { this.synOnlineWaitMs = synOnlineWaitMs; } public String getPvVoltsChannelName() { return pvVoltsChannelName; } public void setPvVoltsChannelName(String pvVoltsChannelName) { this.pvVoltsChannelName = pvVoltsChannelName; setupChannelNamesToMonitor(); } public String getPvAmpsChannelName() { return pvAmpsChannelName; } public void setPvAmpsChannelName(String pvAmpsChannelName) { this.pvAmpsChannelName = pvAmpsChannelName; setupChannelNamesToMonitor(); } public String getkWhChannelName() { return kWhChannelName; } public void setkWhChannelName(String kWhChannelName) { this.kWhChannelName = kWhChannelName; setupChannelNamesToMonitor(); } public SerialPortBeanParameters getSerialParams() { return serialParams; } public void setSerialParams(SerialPortBeanParameters serialParams) { this.serialParams = serialParams; } public DynamicServiceTracker<DataCollectorFactory<SerialPortBeanParameters>> getDataCollectorFactory() { return dataCollectorFactory; } public void setDataCollectorFactory( DynamicServiceTracker<DataCollectorFactory<SerialPortBeanParameters>> dataCollectorFactory) { this.dataCollectorFactory = dataCollectorFactory; } @Override public String getUID() { return getSourceId(); } @Override public String getSourceId() { return sourceId; } @Override public void setSourceId(String sourceId) { this.sourceId = sourceId; } @Override public String getGroupUID() { return groupUID; } @Override public void setGroupUID(String groupUID) { this.groupUID = groupUID; } }