diff --git a/sip-gb28181/pom.xml b/sip-gb28181/pom.xml
new file mode 100644
index 0000000..f588884
--- /dev/null
+++ b/sip-gb28181/pom.xml
@@ -0,0 +1,64 @@
+
+
+ 4.0.0
+
+
+ com.sip
+ sip-stack-parent
+ 0.1.0-SNAPSHOT
+
+
+ sip-gb28181
+ SIP :: GB28181 Application Layer
+
+ GB/T 28181-2016 video surveillance application-layer messages
+ (MANSCDP XML over MESSAGE / NOTIFY) and supporting types.
+ Builds on sip-message + sip-codec; consumes sip-ua for register flow.
+
+
+
+
+ com.sip
+ sip-message
+
+
+ com.sip
+ sip-codec
+
+
+ com.sip
+ sip-ua
+
+
+ org.slf4j
+ slf4j-api
+
+
+ org.slf4j
+ slf4j-simple
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ -Xlint:all,-serial,-processing,-module
+ -Werror
+ -parameters
+
+
+
+
+
+
diff --git a/sip-gb28181/src/main/java/com/sip/gb28181/GbCommand.java b/sip-gb28181/src/main/java/com/sip/gb28181/GbCommand.java
new file mode 100644
index 0000000..c7da275
--- /dev/null
+++ b/sip-gb28181/src/main/java/com/sip/gb28181/GbCommand.java
@@ -0,0 +1,60 @@
+package com.sip.gb28181;
+
+/**
+ * GB/T 28181-2016 {@code CmdType} values used in the MANSCDP envelope.
+ *
+ * Each enum constant matches the literal token expected on the wire
+ * (e.g. {@code "Catalog"}, {@code "Keepalive"}). When sent in XML, the
+ * value is wrapped in a {@code ...} element.
+ */
+public enum GbCommand {
+
+ /** Heartbeat — device → platform, periodic alive notification. */
+ KEEPALIVE("Keepalive"),
+
+ /** Catalog query (platform → device) and catalog response (device → platform). */
+ CATALOG("Catalog"),
+
+ /** Device basic info query / response. */
+ DEVICE_INFO("DeviceInfo"),
+
+ /** Device status query / response. */
+ DEVICE_STATUS("DeviceStatus"),
+
+ /** Device control (PTZ, recording, reboot, …). */
+ DEVICE_CONTROL("DeviceControl"),
+
+ /** Device configuration query / response. */
+ DEVICE_CONFIG("DeviceConfig"),
+
+ /** Alarm notification (device → platform). */
+ ALARM("Alarm"),
+
+ /** Mobile-position notification (subscribed location stream). */
+ MOBILE_POSITION("MobilePosition"),
+
+ /** Record history query / response. */
+ RECORD_INFO("RecordInfo"),
+
+ /** Broadcast invite (platform → device, voice broadcast). */
+ BROADCAST("Broadcast");
+
+ private final String wireName;
+
+ GbCommand(String wireName) {
+ this.wireName = wireName;
+ }
+
+ public String wireName() {
+ return wireName;
+ }
+
+ public static GbCommand of(String wireName) {
+ for (GbCommand c : values()) {
+ if (c.wireName.equalsIgnoreCase(wireName)) {
+ return c;
+ }
+ }
+ throw new IllegalArgumentException("unknown GB28181 CmdType: '" + wireName + "'");
+ }
+}
diff --git a/sip-gb28181/src/main/java/com/sip/gb28181/GbMessages.java b/sip-gb28181/src/main/java/com/sip/gb28181/GbMessages.java
new file mode 100644
index 0000000..0fa469c
--- /dev/null
+++ b/sip-gb28181/src/main/java/com/sip/gb28181/GbMessages.java
@@ -0,0 +1,98 @@
+package com.sip.gb28181;
+
+import com.sip.message.SipMethod;
+import com.sip.message.SipRequest;
+import com.sip.message.SipVersion;
+import com.sip.message.header.Headers;
+import com.sip.message.uri.SipUri;
+import com.sip.ua.Branch;
+import com.sip.ua.SipStackInfo;
+
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Builders for the common GB28181 message bodies and matching SIP
+ * envelopes.
+ *
+ * All MANSCDP bodies are emitted as UTF-8 and carried inside a SIP
+ * {@code MESSAGE} request with
+ * {@code Content-Type: Application/MANSCDP+xml}.
+ */
+public final class GbMessages {
+
+ /** MIME media type for MANSCDP XML bodies (GB/T 28181-2016 §9.1). */
+ public static final String MANSCDP_CONTENT_TYPE = "Application/MANSCDP+xml";
+
+ private static final AtomicInteger SN = new AtomicInteger();
+
+ private GbMessages() { }
+
+ /** Allocate a unique serial number for a query/response correlation pair. */
+ public static int nextSerialNumber() {
+ return SN.incrementAndGet();
+ }
+
+ /** Build the XML body for a Keepalive notification. */
+ public static String keepaliveXml(int sn, String deviceId, String status) {
+ return GbXmlBuilder.envelope("Notify")
+ .text("CmdType", GbCommand.KEEPALIVE.wireName())
+ .text("SN", Integer.toString(sn))
+ .text("DeviceID", deviceId)
+ .text("Status", status == null ? "OK" : status)
+ .build();
+ }
+
+ /** Build the XML body for a Catalog query (platform → device). */
+ public static String catalogQueryXml(int sn, String deviceId) {
+ return GbXmlBuilder.envelope("Query")
+ .text("CmdType", GbCommand.CATALOG.wireName())
+ .text("SN", Integer.toString(sn))
+ .text("DeviceID", deviceId)
+ .build();
+ }
+
+ /** Build the XML body for a DeviceInfo query (platform → device). */
+ public static String deviceInfoQueryXml(int sn, String deviceId) {
+ return GbXmlBuilder.envelope("Query")
+ .text("CmdType", GbCommand.DEVICE_INFO.wireName())
+ .text("SN", Integer.toString(sn))
+ .text("DeviceID", deviceId)
+ .build();
+ }
+
+ /** Build the XML body for a DeviceStatus query. */
+ public static String deviceStatusQueryXml(int sn, String deviceId) {
+ return GbXmlBuilder.envelope("Query")
+ .text("CmdType", GbCommand.DEVICE_STATUS.wireName())
+ .text("SN", Integer.toString(sn))
+ .text("DeviceID", deviceId)
+ .build();
+ }
+
+ /**
+ * Wrap an arbitrary MANSCDP XML payload in a SIP MESSAGE request
+ * suitable for sending to {@code targetAor} from {@code localAor}.
+ */
+ public static SipRequest manscdpMessage(SipUri targetAor, SipUri localAor,
+ SipUri requestUri,
+ String fromTag, String callId,
+ int cseqNumber, String xmlBody,
+ String viaSentBy) {
+ byte[] body = xmlBody.getBytes(StandardCharsets.UTF_8);
+ Headers headers = Headers.builder()
+ .add("Via", "SIP/2.0/UDP " + viaSentBy
+ + ";branch=" + Branch.newBranch() + ";rport")
+ .add("Max-Forwards", "70")
+ .add("From", "<" + localAor.asWire() + ">;tag=" + fromTag)
+ .add("To", "<" + targetAor.asWire() + ">")
+ .add("Call-ID", callId)
+ .add("CSeq", cseqNumber + " MESSAGE")
+ .add("Content-Type", MANSCDP_CONTENT_TYPE)
+ .add("Content-Length", Integer.toString(body.length))
+ .add("User-Agent", SipStackInfo.userAgent())
+ .build();
+ return new SipRequest(SipMethod.MESSAGE, requestUri,
+ SipVersion.SIP_2_0, headers, body);
+ }
+}
diff --git a/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlBuilder.java b/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlBuilder.java
new file mode 100644
index 0000000..a56ba56
--- /dev/null
+++ b/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlBuilder.java
@@ -0,0 +1,69 @@
+package com.sip.gb28181;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Tiny element-and-text XML builder tuned for the patterns GB28181 uses.
+ *
+ * GB28181 MANSCDP bodies are flat element trees with no attributes,
+ * no namespaces, and CDATA-free text. A hand-rolled builder is faster
+ * and less error-prone than dragging in JAXB just for this.
+ *
+ * {@code
+ * String xml = GbXmlBuilder.envelope("Query")
+ * .text("CmdType", "Catalog")
+ * .text("SN", "1")
+ * .text("DeviceID", "34020000001320000001")
+ * .build();
+ * }
+ *
+ * Output is always declared as UTF-8 and ends with a single newline.
+ */
+public final class GbXmlBuilder {
+
+ private final String rootElement;
+ private final Map children = new LinkedHashMap<>();
+
+ private GbXmlBuilder(String rootElement) {
+ this.rootElement = rootElement;
+ }
+
+ public static GbXmlBuilder envelope(String rootElement) {
+ return new GbXmlBuilder(rootElement);
+ }
+
+ public GbXmlBuilder text(String element, String value) {
+ children.put(element, value == null ? "" : value);
+ return this;
+ }
+
+ public String build() {
+ StringBuilder sb = new StringBuilder(256);
+ sb.append("\n");
+ sb.append('<').append(rootElement).append(">\n");
+ for (var e : children.entrySet()) {
+ sb.append(" <").append(e.getKey()).append('>')
+ .append(escape(e.getValue()))
+ .append("").append(e.getKey()).append(">\n");
+ }
+ sb.append("").append(rootElement).append(">\n");
+ return sb.toString();
+ }
+
+ private static String escape(String s) {
+ StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '<' -> sb.append("<");
+ case '>' -> sb.append(">");
+ case '&' -> sb.append("&");
+ case '"' -> sb.append(""");
+ case '\'' -> sb.append("'");
+ default -> sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git a/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlParser.java b/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlParser.java
new file mode 100644
index 0000000..0c4a1b7
--- /dev/null
+++ b/sip-gb28181/src/main/java/com/sip/gb28181/GbXmlParser.java
@@ -0,0 +1,146 @@
+package com.sip.gb28181;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Tiny XML parser tuned for GB28181 MANSCDP bodies.
+ *
+ * Handles the limited subset GB28181 actually uses: a single root
+ * element containing flat {@code text} children. No
+ * attributes, no nested elements, no namespaces.
+ *
+ * For documents outside this subset (e.g. nested {@code }
+ * with {@code - } children), use a real XML parser. The parser is
+ * intentionally minimal to keep the module dependency-free.
+ */
+public final class GbXmlParser {
+
+ private GbXmlParser() { }
+
+ public static Parsed parse(String xml) {
+ if (xml == null) {
+ throw new IllegalArgumentException("xml is null");
+ }
+ String s = xml.trim();
+ // Strip XML declaration if present.
+ if (s.startsWith("");
+ if (end < 0) {
+ throw new IllegalArgumentException("unterminated XML declaration");
+ }
+ s = s.substring(end + 2).trim();
+ }
+
+ int rootOpen = s.indexOf('<');
+ if (rootOpen < 0) {
+ throw new IllegalArgumentException("no root element");
+ }
+ int rootOpenEnd = s.indexOf('>', rootOpen + 1);
+ if (rootOpenEnd < 0) {
+ throw new IllegalArgumentException("unterminated root opening tag");
+ }
+ String rootName = s.substring(rootOpen + 1, rootOpenEnd).trim();
+ String closeTag = "" + rootName + ">";
+ int closeIdx = s.lastIndexOf(closeTag);
+ if (closeIdx < 0) {
+ throw new IllegalArgumentException("missing closing tag for <" + rootName + ">");
+ }
+
+ String inner = s.substring(rootOpenEnd + 1, closeIdx);
+ Map children = new LinkedHashMap<>();
+ int i = 0;
+ while (i < inner.length()) {
+ int open = inner.indexOf('<', i);
+ if (open < 0) break;
+ int openEnd = inner.indexOf('>', open + 1);
+ if (openEnd < 0) {
+ throw new IllegalArgumentException("unterminated opening tag at offset " + open);
+ }
+ String tagOpen = inner.substring(open + 1, openEnd).trim();
+ if (tagOpen.startsWith("/")) {
+ // mismatched closer
+ throw new IllegalArgumentException(
+ "unexpected closing tag: '" + tagOpen + "'");
+ }
+ // self-closing?
+ if (tagOpen.endsWith("/")) {
+ String name = tagOpen.substring(0, tagOpen.length() - 1).trim();
+ children.put(name, "");
+ i = openEnd + 1;
+ continue;
+ }
+ String name = tagOpen;
+ // find matching closer
+ String close = "" + name + ">";
+ int cIdx = inner.indexOf(close, openEnd + 1);
+ if (cIdx < 0) {
+ throw new IllegalArgumentException("missing closing tag for <" + name + ">");
+ }
+ String value = unescape(inner.substring(openEnd + 1, cIdx));
+ children.put(name, value);
+ i = cIdx + close.length();
+ }
+ return new Parsed(rootName, children);
+ }
+
+ private static String unescape(String s) {
+ StringBuilder sb = new StringBuilder(s.length());
+ int i = 0;
+ while (i < s.length()) {
+ char c = s.charAt(i);
+ if (c == '&') {
+ int semi = s.indexOf(';', i + 1);
+ if (semi < 0) {
+ sb.append(c);
+ i++;
+ continue;
+ }
+ String entity = s.substring(i + 1, semi);
+ switch (entity) {
+ case "lt" -> sb.append('<');
+ case "gt" -> sb.append('>');
+ case "amp" -> sb.append('&');
+ case "quot" -> sb.append('"');
+ case "apos" -> sb.append('\'');
+ default -> {
+ if (entity.startsWith("#")) {
+ try {
+ int code = entity.startsWith("#x") || entity.startsWith("#X")
+ ? Integer.parseInt(entity.substring(2), 16)
+ : Integer.parseInt(entity.substring(1));
+ sb.appendCodePoint(code);
+ } catch (NumberFormatException ignore) {
+ sb.append('&').append(entity).append(';');
+ }
+ } else {
+ sb.append('&').append(entity).append(';');
+ }
+ }
+ }
+ i = semi + 1;
+ } else {
+ sb.append(c);
+ i++;
+ }
+ }
+ return sb.toString();
+ }
+
+ /** Result of parsing a flat MANSCDP-style XML document. */
+ public record Parsed(String rootElement, Map children) {
+
+ public Parsed {
+ children = Map.copyOf(children);
+ }
+
+ public Optional get(String name) {
+ return Optional.ofNullable(children.get(name));
+ }
+
+ public Optional command() {
+ return get("CmdType").map(GbCommand::of);
+ }
+ }
+}
diff --git a/sip-gb28181/src/main/java/module-info.java b/sip-gb28181/src/main/java/module-info.java
new file mode 100644
index 0000000..59e8072
--- /dev/null
+++ b/sip-gb28181/src/main/java/module-info.java
@@ -0,0 +1,25 @@
+/**
+ * GB/T 28181-2016 application-layer messages.
+ *
+ * GB28181 carries device control and event data inside SIP MESSAGE /
+ * NOTIFY bodies using MANSCDP XML. This module provides:
+ *
+ *
+ * - {@link com.sip.gb28181.GbCommand} — the canonical command vocabulary
+ * (Catalog, Keepalive, DeviceInfo, Alarm, …)
+ * - {@link com.sip.gb28181.GbXmlBuilder} / {@link com.sip.gb28181.GbXmlParser}
+ * — minimal XML support tuned to the patterns GB28181 actually uses
+ * - High-level message DTOs and assemblers for the common cases.
+ *
+ *
+ * For SIP framing, devices use the normal {@code MESSAGE} method with
+ * {@code Content-Type: Application/MANSCDP+xml}.
+ */
+module com.sip.gb28181 {
+ requires transitive com.sip.message;
+ requires transitive com.sip.ua;
+ requires com.sip.codec;
+ requires org.slf4j;
+
+ exports com.sip.gb28181;
+}
diff --git a/sip-gb28181/src/test/java/com/sip/gb28181/GbMessagesTest.java b/sip-gb28181/src/test/java/com/sip/gb28181/GbMessagesTest.java
new file mode 100644
index 0000000..d3599ab
--- /dev/null
+++ b/sip-gb28181/src/test/java/com/sip/gb28181/GbMessagesTest.java
@@ -0,0 +1,74 @@
+package com.sip.gb28181;
+
+import com.sip.codec.SipEncoder;
+import com.sip.codec.SipParser;
+import com.sip.message.SipMessage;
+import com.sip.message.SipMethod;
+import com.sip.message.SipRequest;
+import com.sip.message.header.HeaderName;
+import com.sip.message.uri.SipUri;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GbMessagesTest {
+
+ @Test
+ void manscdpMessageEncodesAndParsesIdentically() {
+ SipUri target = SipUri.builder()
+ .user("34020000001320000001").host("3402000000").build();
+ SipUri local = SipUri.builder()
+ .user("34020000002000000001").host("3402000000").build();
+ String xml = GbMessages.keepaliveXml(1, "34020000001320000001", "OK");
+
+ SipRequest req = GbMessages.manscdpMessage(target, local, target,
+ "ftag-1", "callid@gb28181", 1, xml, "127.0.0.1:5060");
+
+ // wire encode → parse → verify
+ byte[] bytes = SipEncoder.encode(req);
+ SipMessage parsed = SipParser.parse(bytes);
+ assertThat(parsed).isInstanceOf(SipRequest.class);
+ SipRequest got = (SipRequest) parsed;
+ assertThat(got.method()).isEqualTo(SipMethod.MESSAGE);
+ assertThat(got.requestUri().asWire())
+ .isEqualTo("sip:34020000001320000001@3402000000");
+ assertThat(got.headers().first(HeaderName.CONTENT_TYPE).orElseThrow().value())
+ .isEqualTo(GbMessages.MANSCDP_CONTENT_TYPE);
+
+ String parsedBody = new String(got.body(), StandardCharsets.UTF_8);
+ assertThat(parsedBody).contains("Keepalive");
+ assertThat(parsedBody).contains("34020000001320000001");
+
+ GbXmlParser.Parsed xmlParsed = GbXmlParser.parse(parsedBody);
+ assertThat(xmlParsed.command()).contains(GbCommand.KEEPALIVE);
+ }
+
+ @Test
+ void catalogQueryRoundtripsThroughSipAndXml() {
+ SipUri target = SipUri.builder().user("dev").host("h").build();
+ SipUri local = SipUri.builder().user("plat").host("h").build();
+ int sn = GbMessages.nextSerialNumber();
+ String xml = GbMessages.catalogQueryXml(sn, "34020000001320000001");
+
+ SipRequest req = GbMessages.manscdpMessage(target, local, target,
+ "ftag-2", "cid@x", 1, xml, "127.0.0.1:5060");
+ byte[] bytes = SipEncoder.encode(req);
+
+ SipRequest parsed = (SipRequest) SipParser.parse(bytes);
+ String body = new String(parsed.body(), StandardCharsets.UTF_8);
+ GbXmlParser.Parsed p = GbXmlParser.parse(body);
+ assertThat(p.rootElement()).isEqualTo("Query");
+ assertThat(p.command()).contains(GbCommand.CATALOG);
+ assertThat(p.get("SN")).contains(Integer.toString(sn));
+ }
+
+ @Test
+ void serialNumbersAreMonotonic() {
+ int a = GbMessages.nextSerialNumber();
+ int b = GbMessages.nextSerialNumber();
+ int c = GbMessages.nextSerialNumber();
+ assertThat(a).isLessThan(b).isLessThan(c);
+ }
+}
diff --git a/sip-gb28181/src/test/java/com/sip/gb28181/GbXmlTest.java b/sip-gb28181/src/test/java/com/sip/gb28181/GbXmlTest.java
new file mode 100644
index 0000000..a99fb60
--- /dev/null
+++ b/sip-gb28181/src/test/java/com/sip/gb28181/GbXmlTest.java
@@ -0,0 +1,94 @@
+package com.sip.gb28181;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class GbXmlTest {
+
+ @Test
+ void buildsKeepaliveXml() {
+ String xml = GbMessages.keepaliveXml(42, "34020000001320000001", "OK");
+ assertThat(xml).contains("");
+ assertThat(xml).contains("Keepalive");
+ assertThat(xml).contains("42");
+ assertThat(xml).contains("34020000001320000001");
+ assertThat(xml).contains("OK");
+ }
+
+ @Test
+ void buildsCatalogQueryXml() {
+ String xml = GbMessages.catalogQueryXml(7, "34020000001320000001");
+ assertThat(xml).contains("Catalog");
+ assertThat(xml).contains("7");
+ }
+
+ @Test
+ void parsesFlatEnvelope() {
+ String xml = """
+
+
+ Keepalive
+ 1
+ 34020000001320000001
+ OK
+
+ """;
+ GbXmlParser.Parsed p = GbXmlParser.parse(xml);
+ assertThat(p.rootElement()).isEqualTo("Notify");
+ assertThat(p.command()).contains(GbCommand.KEEPALIVE);
+ assertThat(p.get("SN")).contains("1");
+ assertThat(p.get("DeviceID")).contains("34020000001320000001");
+ }
+
+ @Test
+ void roundtripsKeepalive() {
+ String original = GbMessages.keepaliveXml(99, "34020000001320000001", "OK");
+ GbXmlParser.Parsed p = GbXmlParser.parse(original);
+ assertThat(p.rootElement()).isEqualTo("Notify");
+ assertThat(p.command()).contains(GbCommand.KEEPALIVE);
+ assertThat(p.get("SN")).contains("99");
+ assertThat(p.get("Status")).contains("OK");
+ }
+
+ @Test
+ void unescapesEntities() {
+ String xml = """
+
+ A & B <test>
+
+ """;
+ GbXmlParser.Parsed p = GbXmlParser.parse(xml);
+ assertThat(p.get("Name")).contains("A & B ");
+ }
+
+ @Test
+ void escapesSpecialChars() {
+ String xml = GbXmlBuilder.envelope("Foo")
+ .text("Bar", "A & B ")
+ .build();
+ assertThat(xml).contains("&");
+ assertThat(xml).contains("<");
+ assertThat(xml).contains(">");
+ // round-trip
+ assertThat(GbXmlParser.parse(xml).get("Bar"))
+ .contains("A & B ");
+ }
+
+ @Test
+ void rejectsUnknownCmdType() {
+ assertThatThrownBy(() -> GbCommand.of("WhatIsThis"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void recognizesAllStandardCommands() {
+ assertThat(GbCommand.of("Catalog")).isEqualTo(GbCommand.CATALOG);
+ assertThat(GbCommand.of("DeviceInfo")).isEqualTo(GbCommand.DEVICE_INFO);
+ assertThat(GbCommand.of("Alarm")).isEqualTo(GbCommand.ALARM);
+ assertThat(GbCommand.of("MobilePosition")).isEqualTo(GbCommand.MOBILE_POSITION);
+ // case insensitive
+ assertThat(GbCommand.of("catalog")).isEqualTo(GbCommand.CATALOG);
+ }
+}