/* ==================================================================
* RFXCOMConsumptionDatumDataSource.java - Jul 8, 2012 4:44:30 PM
*
* Copyright 2007-2012 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.consumption.rfxcom;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.concurrent.ConcurrentSkipListSet;
import net.solarnetwork.node.ConversationalDataCollector;
import net.solarnetwork.node.DatumDataSource;
import net.solarnetwork.node.MultiDatumDataSource;
import net.solarnetwork.node.consumption.ConsumptionDatum;
import net.solarnetwork.node.rfxcom.AddressSource;
import net.solarnetwork.node.rfxcom.CurrentMessage;
import net.solarnetwork.node.rfxcom.EnergyMessage;
import net.solarnetwork.node.rfxcom.Message;
import net.solarnetwork.node.rfxcom.MessageFactory;
import net.solarnetwork.node.rfxcom.MessageListener;
import net.solarnetwork.node.rfxcom.RFXCOM;
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.node.support.SerialPortBeanParameters;
import net.solarnetwork.node.util.PrefixedMessageSource;
import net.solarnetwork.util.OptionalService;
import net.solarnetwork.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.support.ResourceBundleMessageSource;
/**
* {@link MultiDatumDataSource} for {@link ConsumptionDatum} entities read from
* the supported energy formats of the RFXCOM transceiver.
*
* @author matt
* @version 1.1
*/
public class RFXCOMConsumptionDatumDataSource implements DatumDataSource<ConsumptionDatum>,
MultiDatumDataSource<ConsumptionDatum>, ConversationalDataCollector.Moderator<List<Message>>,
SettingSpecifierProvider {
/**
* The default value for the {@code maxWattHourSpikeVerificationDiff}
* property.
*/
private static final long DEFAULT_MAX_WATT_HOUR_SPIKE_VERIFICATION_DIFF = 5000L;
/** The default value for the {@code maxWattHourVerificationDiff} property. */
private static final long DEFAULT_MAX_WATT_HOUR_WARMUP_VERIFICATION_DIFF = 500L;
/** The default value for the {@code collectAllSourceIdsTimeout} property. */
public static final int DEFAULT_COLLECT_ALL_SOURCE_IDS_TIMEOUT = 55;
/** The default value for the {@code voltage} property. */
public static final float DEFAULT_VOLTAGE = 230.0F;
/** The default value for the {@code currentSensorIndexFlags} property. */
public static final int DEFAULT_CURRENT_SENSOR_INDEX_FLAGS = 1;
private static final Object MONITOR = new Object();
private static MessageSource MESSAGE_SOURCE;
private OptionalService<RFXCOM> rfxcomTracker;
private Map<String, String> addressSourceMapping = null;
private Set<String> sourceIdFilter = null;
private boolean collectAllSourceIds = true;
private int collectAllSourceIdsTimeout = DEFAULT_COLLECT_ALL_SOURCE_IDS_TIMEOUT;
private float voltage = DEFAULT_VOLTAGE;
private int currentSensorIndexFlags = DEFAULT_CURRENT_SENSOR_INDEX_FLAGS;
private String uid;
private String groupUID;
// some in-memory error correction support, map keys are source IDs
private long maxWattHourWarmupVerificationDiff = DEFAULT_MAX_WATT_HOUR_WARMUP_VERIFICATION_DIFF;
private long maxWattHourSpikeVerificationDiff = DEFAULT_MAX_WATT_HOUR_SPIKE_VERIFICATION_DIFF;
private final Map<String, Long> previousWattHours = new HashMap<String, Long>();
private final Map<String, List<ConsumptionDatum>> datumBuffer = new HashMap<String, List<ConsumptionDatum>>();
// in-memory listing of "seen" addresses, to support device discovery
private final SortedSet<AddressSource> knownAddresses = new ConcurrentSkipListSet<AddressSource>();
private final Logger log = LoggerFactory.getLogger(getClass());
/**
* Add a new cached "known" address value.
*
* <p>
* This adds the address to the cached set of <em>known</em> addresses,
* which are shown as a read-only setting property to aid in mapping the
* right RFXCOM-recognized device address.
* </p>
*
* @param datum
* the datum to add
*/
private void addKnownAddress(AddressSource datum) {
knownAddresses.add(datum);
}
@Override
public Class<? extends ConsumptionDatum> getMultiDatumType() {
return ConsumptionDatum.class;
}
@Override
public Class<? extends ConsumptionDatum> getDatumType() {
return ConsumptionDatum.class;
}
private String getSourceIdForMessageAddress(String addr) {
if ( getAddressSourceMapping() != null && getAddressSourceMapping().containsKey(addr) ) {
addr = getAddressSourceMapping().get(addr);
}
return addr;
}
private ConsumptionDatum filterConsumptionDatumInstance(ConsumptionDatum d) {
String addr = getSourceIdForMessageAddress(d.getSourceId());
if ( getSourceIdFilter() != null && !getSourceIdFilter().contains(addr) ) {
if ( log.isInfoEnabled() ) {
log.info("Rejecting source [" + addr + "] not in source ID filter set");
}
return null;
}
// create a copy, because CurrentMessage might still be using input object for
// other sensors...
ConsumptionDatum copy = (ConsumptionDatum) d.clone();
copy.setSourceId(addr);
copy.setCreated(new Date());
return copy;
}
private List<ConsumptionDatum> getDatumBufferForSource(String source) {
if ( !datumBuffer.containsKey(source) ) {
datumBuffer.put(source, new ArrayList<ConsumptionDatum>(5));
}
return datumBuffer.get(source);
}
private void addToResultsCheckingData(ConsumptionDatum datum, List<ConsumptionDatum> results) {
if ( datum == null ) {
return;
}
final String sourceId = (datum.getSourceId() == null ? "" : datum.getSourceId());
if ( sourceIdFilter != null && !sourceIdFilter.contains(sourceId) ) {
return;
}
if ( datum.getWattHourReading() != null ) {
// calculate what would be the Wh diff
Long prevGoodWh = previousWattHours.get(sourceId);
List<ConsumptionDatum> buffer = getDatumBufferForSource(sourceId);
if ( (prevGoodWh == null && buffer.size() < 2) || (buffer.size() > 0 && buffer.size() < 2) ) {
// don't know the Wh diff, or we've buffered one item, so buffer this value so we have
// two buffered, because Wh might go back to zero when the transmitter is reset
log.info("Buffering datum until enough collected for data verification: {}", datum);
buffer.add(datum);
return;
} else if ( buffer.size() == 2 ) {
// so we have 2 buffered items... and this new item. We expect this new item to have
// Wh >= the last buffered item >= the first buffered item
long diff = datum.getWattHourReading() - buffer.get(1).getWattHourReading();
long diff2 = buffer.get(1).getWattHourReading() - buffer.get(0).getWattHourReading();
if ( datum.getWattHourReading() >= buffer.get(1).getWattHourReading()
&& buffer.get(1).getWattHourReading() >= buffer.get(0).getWattHourReading()
&& (diff2 - diff) < maxWattHourWarmupVerificationDiff ) {
prevGoodWh = buffer.get(1).getWattHourReading();
results.addAll(buffer);
buffer.clear();
} else {
// discard the oldest buffered item, and buffer our new one
log.warn("Discarding datum that failed data validation: {}", buffer.get(0));
buffer.remove(0);
buffer.add(datum);
return;
}
}
if ( datum.getWattHourReading() < prevGoodWh ) {
log.info("Buffering datum to verify data with next read (Wh decreased): {}", datum);
buffer.add(datum);
return;
}
long whDiff = Math.abs(datum.getWattHourReading() - prevGoodWh);
if ( whDiff >= maxWattHourSpikeVerificationDiff ) {
log.info("Buffering datum to verify data with next read ({} Wh spike): {}", whDiff,
datum);
buffer.add(datum);
return;
}
previousWattHours.put(sourceId, datum.getWattHourReading());
}
results.add(datum);
}
private void addConsumptionDatumFromMessage(Message msg, List<ConsumptionDatum> results) {
final String address = ((AddressSource) msg).getAddress();
if ( msg instanceof EnergyMessage ) {
EnergyMessage emsg = (EnergyMessage) msg;
ConsumptionDatum d = new ConsumptionDatum();
d.setSourceId(address);
final double wh = emsg.getUsageWattHours();
final double w = emsg.getInstantWatts();
if ( wh > 0 ) {
d.setWattHourReading(Math.round(wh));
}
d.setWatts((int) Math.ceil(w));
d = filterConsumptionDatumInstance(d);
addToResultsCheckingData(d, results);
} else {
// assume CurrentMessage
CurrentMessage cmsg = (CurrentMessage) msg;
ConsumptionDatum d = new ConsumptionDatum();
// we turn each sensor into its own ConsumptionDatum, the sensors we collect
// from are specified by the currentSensorIndexFlags property
for ( int i = 1; i <= 3; i++ ) {
if ( (i & currentSensorIndexFlags) != i ) {
continue;
}
d.setSourceId(address + "." + i);
switch (i) {
case 1:
d.setWatts((int) Math.ceil(voltage * cmsg.getAmpReading1()));
break;
case 2:
d.setWatts((int) Math.ceil(voltage * cmsg.getAmpReading2()));
break;
case 3:
d.setWatts((int) Math.ceil(voltage * cmsg.getAmpReading3()));
break;
}
ConsumptionDatum filtered = filterConsumptionDatumInstance(d);
addToResultsCheckingData(filtered, results);
}
}
}
@Override
public ConsumptionDatum readCurrentDatum() {
final Collection<ConsumptionDatum> results = readMultipleDatum();
if ( results != null && results.size() > 0 ) {
return results.iterator().next();
}
return null;
}
@Override
public Collection<ConsumptionDatum> readMultipleDatum() {
final RFXCOM r = getRfxcomTracker().service();
if ( r == null ) {
return null;
}
final List<Message> messages;
final ConversationalDataCollector dc = r.getDataCollectorInstance();
if ( dc == null ) {
return null;
}
try {
messages = dc.collectData(this);
} finally {
if ( dc != null ) {
dc.stopCollecting();
}
}
if ( messages == null ) {
return null;
}
final List<ConsumptionDatum> results = new ArrayList<ConsumptionDatum>(messages.size());
for ( Message msg : messages ) {
addConsumptionDatumFromMessage(msg, results);
}
return results;
}
@Override
public List<Message> conductConversation(ConversationalDataCollector dc) {
final List<Message> result = new ArrayList<Message>(3);
final long endTime = (isCollectAllSourceIds() && getSourceIdFilter() != null
&& getSourceIdFilter().size() > 1 ? System.currentTimeMillis()
+ (getCollectAllSourceIdsTimeout() * 1000) : 0);
final Set<String> sourceIdSet = new HashSet<String>(getSourceIdFilter() == null ? 0
: getSourceIdFilter().size());
final MessageFactory mf = new MessageFactory();
final MessageListener listener = new MessageListener();
do {
listener.reset();
dc.listen(listener);
byte[] data = dc.getCollectedData();
if ( data == null ) {
log.warn("Null serial data received, serial communications problem");
return null;
}
Message msg = mf.parseMessage(data, 0);
if ( msg instanceof AddressSource ) {
// add a known address for this reading
addKnownAddress((AddressSource) msg);
}
if ( msg instanceof EnergyMessage || msg instanceof CurrentMessage ) {
final String sourceId = getSourceIdForMessageAddress(((AddressSource) msg).getAddress());
if ( !sourceIdSet.contains(sourceId) ) {
result.add(msg);
sourceIdSet.add(sourceId);
}
}
} while ( System.currentTimeMillis() < endTime
&& sourceIdSet.size() < (getSourceIdFilter() == null ? 0 : getSourceIdFilter().size()) );
return result;
}
@Override
public String getSettingUID() {
return "net.solarnetwork.node.consumption.rfxcom";
}
@Override
public String getDisplayName() {
return "RFXCOM energy consumption meter";
}
@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(RFXCOMConsumptionDatumDataSource.class.getClassLoader());
source.setBasename(RFXCOMConsumptionDatumDataSource.class.getName());
source.setParentMessageSource(serialSource);
MESSAGE_SOURCE = source;
}
}
return MESSAGE_SOURCE;
}
@Override
public List<SettingSpecifier> getSettingSpecifiers() {
RFXCOMConsumptionDatumDataSource defaults = new RFXCOMConsumptionDatumDataSource();
List<SettingSpecifier> results = new ArrayList<SettingSpecifier>(21);
results.add(new BasicTextFieldSettingSpecifier("uid", defaults.uid));
results.add(new BasicTextFieldSettingSpecifier("groupUID", defaults.groupUID));
results.add(new BasicTextFieldSettingSpecifier("rfxcomTracker.propertyFilters['UID']",
"/dev/ttyUSB0"));
StringBuilder status = new StringBuilder();
for ( AddressSource datum : knownAddresses ) {
if ( status.length() > 0 ) {
status.append(",\n");
}
status.append(datum.toString());
}
results.add(new BasicTitleSettingSpecifier("knownAddresses", status.toString(), true));
results.add(new BasicTextFieldSettingSpecifier("addressSourceMappingValue", ""));
results.add(new BasicTextFieldSettingSpecifier("sourceIdFilterValue", ""));
results.add(new BasicToggleSettingSpecifier("collectAllSourceIds", defaults.collectAllSourceIds));
results.add(new BasicTextFieldSettingSpecifier("collectAllSourceIdsTimeout", String
.valueOf(defaults.collectAllSourceIdsTimeout)));
results.add(new BasicTextFieldSettingSpecifier("currentSensorIndexFlags", String
.valueOf(defaults.currentSensorIndexFlags)));
results.add(new BasicTextFieldSettingSpecifier("maxWattHourWarmupVerificationDiff", String
.valueOf(defaults.getMaxWattHourWarmupVerificationDiff())));
results.add(new BasicTextFieldSettingSpecifier("maxWattHourSpikeVerificationDiff", String
.valueOf(defaults.getMaxWattHourSpikeVerificationDiff())));
return results;
}
/**
* Set a {@code addressSourceMapping} 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
*/
public void setAddressSourceMappingValue(String mapping) {
setAddressSourceMapping(StringUtils.commaDelimitedStringToMap(mapping));
}
/**
* Set a {@link sourceIdFilter} List via an encoded String value.
*
* <p>
* The format of the {@code filters} String should be a comma-delimited list
* of values. Whitespace is permitted around the commas, and will be
* stripped from the values.
* </p>
*
* @param filters
*/
public void setSourceIdFilterValue(String filters) {
setSourceIdFilter(StringUtils.commaDelimitedStringToSet(filters));
}
public OptionalService<RFXCOM> getRfxcomTracker() {
return rfxcomTracker;
}
public void setRfxcomTracker(OptionalService<RFXCOM> rfxcomTracker) {
this.rfxcomTracker = rfxcomTracker;
}
public Map<String, String> getAddressSourceMapping() {
return addressSourceMapping;
}
public void setAddressSourceMapping(Map<String, String> addressSourceMapping) {
this.addressSourceMapping = addressSourceMapping;
}
public Set<String> getSourceIdFilter() {
return sourceIdFilter;
}
public void setSourceIdFilter(Set<String> sourceIdFilter) {
this.sourceIdFilter = sourceIdFilter;
}
public boolean isCollectAllSourceIds() {
return collectAllSourceIds;
}
public void setCollectAllSourceIds(boolean collectAllSourceIds) {
this.collectAllSourceIds = collectAllSourceIds;
}
public int getCollectAllSourceIdsTimeout() {
return collectAllSourceIdsTimeout;
}
public void setCollectAllSourceIdsTimeout(int collectAllSourceIdsTimeout) {
this.collectAllSourceIdsTimeout = collectAllSourceIdsTimeout;
}
public float getVoltage() {
return voltage;
}
public void setVoltage(float voltage) {
this.voltage = voltage;
}
public int getCurrentSensorIndexFlags() {
return currentSensorIndexFlags;
}
public void setCurrentSensorIndexFlags(int currentSensorIndexFlags) {
this.currentSensorIndexFlags = currentSensorIndexFlags;
}
public long getMaxWattHourWarmupVerificationDiff() {
return maxWattHourWarmupVerificationDiff;
}
public void setMaxWattHourWarmupVerificationDiff(long maxWattHourVerificationDiff) {
this.maxWattHourWarmupVerificationDiff = maxWattHourVerificationDiff;
}
public long getMaxWattHourSpikeVerificationDiff() {
return maxWattHourSpikeVerificationDiff;
}
public void setMaxWattHourSpikeVerificationDiff(long maxWattHourSpikeVerificationDiff) {
this.maxWattHourSpikeVerificationDiff = maxWattHourSpikeVerificationDiff;
}
@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;
}
}