package com.battery.device.bts;
|
|
import java.io.StringReader;
|
import java.math.BigDecimal;
|
import java.time.Instant;
|
import java.time.LocalDateTime;
|
import java.time.ZoneId;
|
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeParseException;
|
import java.util.ArrayList;
|
import java.util.HashMap;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Set;
|
import javax.xml.XMLConstants;
|
import javax.xml.parsers.DocumentBuilder;
|
import javax.xml.parsers.DocumentBuilderFactory;
|
import org.w3c.dom.Document;
|
import org.w3c.dom.Element;
|
import org.w3c.dom.NamedNodeMap;
|
import org.w3c.dom.Node;
|
import org.w3c.dom.NodeList;
|
import org.xml.sax.InputSource;
|
import org.xml.sax.helpers.DefaultHandler;
|
import com.battery.device.api.model.ChannelIdentity;
|
import com.battery.device.api.model.DeviceConnectionStatus;
|
import com.battery.device.api.model.DetectedChannel;
|
import com.battery.device.api.model.DetectedDeviceMetadata;
|
import com.battery.device.api.model.DeviceWorkStatus;
|
import com.battery.device.api.model.Measurement;
|
import com.battery.device.api.model.PlannedStep;
|
import com.battery.device.api.model.RealtimeSnapshot;
|
|
public final class BtsXmlCodec
|
{
|
private static final DateTimeFormatter BTS_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH.mm.ss");
|
private static final Set<String> SNAPSHOT_ATTRIBUTES = Set.of(
|
"dev", "cycle_id", "step_id", "step_type", "workstatus", "barcode", "current", "voltage",
|
"capacity", "energy", "totaltime", "relativetime", "auxtemp", "auxvol", "open_or_close", "log_code",
|
"device_time", "deviceTime", "time", "atime", "testtime");
|
|
private final String adapterCode;
|
private final ZoneId deviceZone;
|
|
public BtsXmlCodec(String adapterCode, ZoneId deviceZone)
|
{
|
if (adapterCode == null || adapterCode.isBlank()) throw new IllegalArgumentException("adapterCode must not be blank");
|
this.adapterCode = adapterCode.trim();
|
this.deviceZone = deviceZone;
|
}
|
|
public String getDeviceInfoRequest()
|
{
|
return envelope("<cmd>getdevinfo</cmd>");
|
}
|
|
public String inquireRequest(Set<ChannelIdentity> channels, boolean includeBarcode)
|
{
|
StringBuilder list = new StringBuilder("<cmd>inquire</cmd><list count=\"").append(channels.size()).append("\">");
|
for (ChannelIdentity channel : channels)
|
{
|
requireOwned(channel);
|
list.append("<inquire ip=\"127.0.0.1\" devtype=\"").append(channel.devtype())
|
.append("\" devid=\"").append(channel.devid())
|
.append("\" subdevid=\"").append(channel.subdevid())
|
.append("\" chlid=\"").append(channel.chlid())
|
.append("\" aux=\"0\" barcode=\"").append(includeBarcode ? 1 : 0).append("\">true</inquire>");
|
}
|
return envelope(list.append("</list>").toString());
|
}
|
|
|
|
public List<DetectedChannel> parseDeviceInfo(String xml)
|
{
|
Document document = parse(xml, "getdevinfo_resp");
|
NodeList nodes = firstElement(document, "middle").getElementsByTagName("channel");
|
List<DetectedChannel> channels = new ArrayList<>(nodes.getLength());
|
for (int i = 0; i < nodes.getLength(); i++)
|
{
|
Element element = (Element) nodes.item(i);
|
int chlid = integerAttribute(element, element.hasAttribute("Channelid") ? "Channelid" : "chlid");
|
ChannelIdentity identity = new ChannelIdentity(adapterCode, integerAttribute(element, "devtype"),
|
integerAttribute(element, "devid"), integerAttribute(element, "subdevid"), chlid);
|
channels.add(new DetectedChannel(identity, requiredAttribute(element, "ip"),
|
booleanText(element.getTextContent(), "available"),
|
new DetectedDeviceMetadata(optionalAttribute(element, "name"),
|
optionalAttribute(element, "manufacturer"), optionalAttribute(element, "model"),
|
optionalAttribute(element, "mac"))));
|
}
|
return List.copyOf(channels);
|
}
|
|
public Map<ChannelIdentity, RealtimeSnapshot> parseSnapshots(String xml, Instant receivedAt)
|
{
|
Document document = parse(xml, "inquire_resp");
|
NodeList nodes = firstElement(document, "list").getElementsByTagName("inquire");
|
Map<ChannelIdentity, RealtimeSnapshot> snapshots = new LinkedHashMap<>();
|
for (int i = 0; i < nodes.getLength(); i++)
|
{
|
Element element = (Element) nodes.item(i);
|
String[] device = requiredAttribute(element, "dev").split("-");
|
if (device.length < 4)
|
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
|
"Invalid BTS dev identity: " + element.getAttribute("dev"));
|
ChannelIdentity identity = new ChannelIdentity(adapterCode, integer(device[0], "devtype"), integer(device[1], "devid"),
|
integer(device[2], "subdevid"), integer(device[3], "chlid"));
|
Instant deviceTime = optionalDeviceTime(element);
|
snapshots.put(identity, new RealtimeSnapshot(identity,
|
DeviceWorkStatus.fromBts(element.getAttribute("workstatus")), optionalInteger(element, "cycle_id"),
|
optionalInteger(element, "step_id"), optionalAttribute(element, "step_type"), optionalAttribute(element, "barcode"),
|
optionalDecimal(element, "current"), optionalDecimal(element, "voltage"), optionalDecimal(element, "capacity"),
|
optionalDecimal(element, "energy"), optionalLong(element, "totaltime"), optionalLong(element, "relativetime"),
|
optionalAttribute(element, "log_code"), receivedAt, dynamicMeasurements(element, SNAPSHOT_ATTRIBUTES),
|
DeviceConnectionStatus.ONLINE, deviceTime, parsePlannedSteps(element), null));
|
}
|
return Map.copyOf(snapshots);
|
}
|
|
|
|
private Document parse(String xml, String expectedCommand)
|
{
|
try
|
{
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
factory.setXIncludeAware(false);
|
factory.setExpandEntityReferences(false);
|
DocumentBuilder builder = factory.newDocumentBuilder();
|
builder.setErrorHandler(new DefaultHandler());
|
Document document = builder.parse(new InputSource(new StringReader(xml)));
|
String command = firstElement(document, "cmd").getTextContent().trim();
|
if (!expectedCommand.equals(command))
|
throw new BtsProtocolException(BtsProtocolException.Reason.UNEXPECTED_COMMAND,
|
"Expected BTS command " + expectedCommand + " but received " + command);
|
return document;
|
}
|
catch (BtsProtocolException ex)
|
{
|
throw ex;
|
}
|
catch (Exception ex)
|
{
|
throw new BtsProtocolException(BtsProtocolException.Reason.MALFORMED_XML,
|
"Invalid BTS XML response", ex);
|
}
|
}
|
|
private Map<String, Measurement> dynamicMeasurements(Element element, Set<String> knownAttributes)
|
{
|
Map<String, Measurement> measurements = new HashMap<>();
|
NamedNodeMap attributes = element.getAttributes();
|
for (int i = 0; i < attributes.getLength(); i++)
|
{
|
Node attribute = attributes.item(i);
|
if (!knownAttributes.contains(attribute.getNodeName()))
|
{
|
BigDecimal value = decimal(attribute.getNodeValue(), attribute.getNodeName());
|
measurements.put(attribute.getNodeName(), new Measurement(attribute.getNodeName(), value, null, "", "AUX"));
|
}
|
}
|
NodeList dbcNodes = element.getElementsByTagName("DBC");
|
for (int i = 0; i < dbcNodes.getLength(); i++)
|
{
|
Element dbc = (Element) dbcNodes.item(i);
|
String code = "DBC:" + requiredAttribute(dbc, "message_name") + ":" + requiredAttribute(dbc, "signal_name");
|
measurements.put(code, new Measurement(code, decimal(requiredAttribute(dbc, "value"), code), null,
|
optionalAttribute(dbc, "unit"), "DBC"));
|
}
|
return Map.copyOf(measurements);
|
}
|
|
private Instant parseDeviceTime(String value)
|
{
|
try
|
{
|
// BTS 时间不带时区,按适配器配置的现场时区解释后统一转换为 UTC 时间点。
|
try
|
{
|
return Instant.parse(value);
|
}
|
catch (DateTimeParseException ignored)
|
{
|
return LocalDateTime.parse(value, BTS_TIME).atZone(deviceZone).toInstant();
|
}
|
}
|
catch (DateTimeParseException ex)
|
{
|
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
|
"Invalid BTS device time: " + value, ex);
|
}
|
}
|
|
private Instant optionalDeviceTime(Element element)
|
{
|
for (String name : List.of("device_time", "deviceTime", "time", "atime", "testtime"))
|
{
|
String value = optionalAttribute(element, name);
|
if (value != null) return parseDeviceTime(value);
|
}
|
return null;
|
}
|
|
private List<PlannedStep> parsePlannedSteps(Element channel)
|
{
|
List<PlannedStep> steps = new ArrayList<>();
|
NodeList nodes = channel.getElementsByTagName("plannedStep");
|
if (nodes.getLength() == 0) nodes = channel.getElementsByTagName("planStep");
|
if (nodes.getLength() == 0) nodes = channel.getElementsByTagName("step");
|
for (int i = 0; i < nodes.getLength(); i++)
|
{
|
Element step = (Element) nodes.item(i);
|
String number = firstOptionalAttribute(step, "step_no", "stepNo", "step_id", "stepid", "id");
|
String type = firstOptionalAttribute(step, "step_type", "stepType", "steptype", "type");
|
if (number == null || type == null) continue;
|
steps.add(new PlannedStep(integer(number, "planned step number"), type,
|
firstOptionalAttribute(step, "name", "step_name", "stepName"),
|
optionalDecimalFromNames(step, "target_voltage", "targetVoltage", "voltage"),
|
optionalDecimalFromNames(step, "target_current", "targetCurrent", "current"),
|
optionalLongFromNames(step, "duration_seconds", "durationSeconds", "duration")));
|
}
|
return List.copyOf(steps);
|
}
|
|
private static String firstOptionalAttribute(Element element, String... names)
|
{
|
for (String name : names)
|
{
|
String value = optionalAttribute(element, name);
|
if (value != null) return value;
|
}
|
return null;
|
}
|
|
private static BigDecimal optionalDecimalFromNames(Element element, String... names)
|
{
|
String value = firstOptionalAttribute(element, names);
|
return value == null ? null : decimal(value, names[0]);
|
}
|
|
private static Long optionalLongFromNames(Element element, String... names)
|
{
|
String value = firstOptionalAttribute(element, names);
|
if (value == null) return null;
|
try { return Long.parseLong(value); }
|
catch (NumberFormatException ex)
|
{
|
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
|
"Invalid planned step duration: " + value, ex);
|
}
|
}
|
|
private void requireOwned(ChannelIdentity channel)
|
{
|
if (!adapterCode.equals(channel.adapterCode())) throw new IllegalArgumentException("Channel belongs to another adapter");
|
}
|
|
private static String envelope(String body)
|
{
|
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">" + body + "</bts>";
|
}
|
|
private static String escape(String value)
|
{
|
return value.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">");
|
}
|
|
private static Element firstElement(Document document, String tag)
|
{
|
NodeList elements = document.getElementsByTagName(tag);
|
if (elements.getLength() == 0)
|
throw new BtsProtocolException(BtsProtocolException.Reason.MISSING_FIELD, "Missing BTS element: " + tag);
|
return (Element) elements.item(0);
|
}
|
|
private static String requiredAttribute(Element element, String name)
|
{
|
String value = element.getAttribute(name);
|
if (value == null || value.isBlank())
|
throw new BtsProtocolException(BtsProtocolException.Reason.MISSING_FIELD, "Missing BTS attribute: " + name);
|
return value.trim();
|
}
|
|
private static String optionalAttribute(Element element, String name)
|
{
|
String value = element.getAttribute(name);
|
return value == null || value.isBlank() ? null : value.trim();
|
}
|
|
private static int integerAttribute(Element element, String name)
|
{
|
return integer(requiredAttribute(element, name), name);
|
}
|
|
private static long longAttribute(Element element, String name)
|
{
|
try
|
{
|
return Long.parseLong(requiredAttribute(element, name));
|
}
|
catch (NumberFormatException ex)
|
{
|
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
|
"Invalid integer BTS attribute: " + name, ex);
|
}
|
}
|
|
private static Integer optionalInteger(Element element, String name)
|
{
|
String value = optionalAttribute(element, name);
|
return value == null ? null : integer(value, name);
|
}
|
|
private static Long optionalLong(Element element, String name)
|
{
|
String value = optionalAttribute(element, name);
|
if (value == null) return null;
|
try
|
{
|
return Long.parseLong(value);
|
}
|
catch (NumberFormatException ex)
|
{
|
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
|
"Invalid integer BTS attribute: " + name, ex);
|
}
|
}
|
|
private static int integer(String value, String name)
|
{
|
try
|
{
|
return Integer.parseInt(value);
|
}
|
catch (NumberFormatException ex)
|
{
|
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
|
"Invalid integer BTS attribute: " + name, ex);
|
}
|
}
|
|
private static BigDecimal optionalDecimal(Element element, String name)
|
{
|
String value = optionalAttribute(element, name);
|
return value == null ? null : decimal(value, name);
|
}
|
|
private static BigDecimal decimal(String value, String name)
|
{
|
try
|
{
|
return new BigDecimal(value);
|
}
|
catch (NumberFormatException ex)
|
{
|
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
|
"Invalid decimal BTS attribute: " + name, ex);
|
}
|
}
|
|
private static boolean booleanText(String value, String name)
|
{
|
String normalized = value == null ? "" : value.trim();
|
if ("true".equalsIgnoreCase(normalized)) return true;
|
if ("false".equalsIgnoreCase(normalized)) return false;
|
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
|
"Invalid boolean BTS value: " + name);
|
}
|
}
|