From 5dc7552099ece0737a61eb98951b7ab19899083f Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 4 Nov 2026 10:02:31 +0000 Subject: [PATCH 1/5] 2.21.0: lewei:// slots for toy drones with a Lewei camera (#262) H.264 over TCP port 7060, with the one byte the camera inverts in each frame of stream type 1 restored. Only video is asked for: no flight commands. --- CHANGELOG.md | 6 + README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- android/app/build.gradle | 4 +- .../java/com/openipc/decoder/Decoder.java | 1 + .../main/java/com/openipc/decoder/Lewei.java | 491 ++++++++++++++++++ .../java/com/openipc/decoder/RtspSession.java | 67 ++- .../com/openipc/decoder/StreamProbes.java | 2 +- .../java/com/openipc/decoder/LeweiTest.java | 365 +++++++++++++ docs/toys.md | 35 +- docs/transports.md | 3 + 12 files changed, 968 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/Lewei.java create mode 100644 android/app/src/test/java/com/openipc/decoder/LeweiTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b15c87b..0808b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ Versions after 2.17 are `MAJOR.MINOR.PATCH` (2.17 is followed by 2.17.1 or 2.18. `docs/testing.md`, "Versions and releases"); the headings below keep the numbers as they were released. +## 2.21.0 — 2026-11-04 + +- Toy drones with a Lewei camera (vendor app WiFi UFO): the new slot URL `lewei://192.168.0.1` + plays their H.264 video from TCP port 7060 and restores the one byte the camera inverts in each + frame (#262). No flight command is sent. + ## 2.20.0 — 2026-11-02 - A remote with only a D-pad now reaches the actions that were touch-only (#263). With the diff --git a/README.md b/README.md index 21b1e5e..3c71992 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ For a box that only shows cameras, the app can open by itself after the device s | [Testing and releases](docs/testing.md) | branches, CI, unit tests, versions and releases, branch builds, `mapping.txt`, building locally | | [Security](docs/security.md) | credential store, debug server (LAN only), log redaction, no encryption on the network | | [Devices](docs/devices.md) | verified and not working devices, hardware decoder notes, device and camera reports | -| [Drones and Wi‑Fi toys](docs/toys.md) | how toys stream, FLOW-UFO, KY UFO and Jieli drones, what to try with an unknown toy, what to send | +| [Drones and Wi‑Fi toys](docs/toys.md) | how toys stream, FLOW-UFO, KY UFO, Jieli and Lewei drones, what to try with an unknown toy, what to send | The change history is the [merged pull requests](https://github.com/OpenIPC/decoder/pulls?q=is%3Apr+is%3Amerged+base%3Av2) and the [commits of v2](https://github.com/OpenIPC/decoder/commits/v2). diff --git a/README.ru.md b/README.ru.md index 5e25d63..f8b4643 100644 --- a/README.ru.md +++ b/README.ru.md @@ -118,7 +118,7 @@ | [Testing and releases](docs/testing.md) | ветки, CI, юнит-тесты, версии и релизы, сборки веток, `mapping.txt`, локальная сборка | | [Security](docs/security.md) | хранилище учётных данных, отладочный сервер (только локальная сеть), скрытие паролей в логах, отсутствие шифрования в сети | | [Devices](docs/devices.md) | проверенные и неработающие устройства, особенности аппаратных декодеров, отчёты об устройствах и камерах | -| [Drones and Wi‑Fi toys](docs/toys.md) | как передают видео игрушки, дроны FLOW-UFO, KY UFO и Jieli, что попробовать с неизвестной игрушкой, что прислать | +| [Drones and Wi‑Fi toys](docs/toys.md) | как передают видео игрушки, дроны FLOW-UFO, KY UFO, Jieli и Lewei, что попробовать с неизвестной игрушкой, что прислать | История изменений — это [принятые pull request'ы](https://github.com/OpenIPC/decoder/pulls?q=is%3Apr+is%3Amerged+base%3Av2) и [коммиты ветки v2](https://github.com/OpenIPC/decoder/commits/v2). diff --git a/README.zh-CN.md b/README.zh-CN.md index 2bde3ce..3b55488 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -118,7 +118,7 @@ | [Testing and releases](docs/testing.md) | 分支、CI、单元测试、版本与发布、分支构建、`mapping.txt`、本地构建 | | [Security](docs/security.md) | 凭据存储、调试服务器(仅限局域网)、日志脱敏、网络传输未加密 | | [Devices](docs/devices.md) | 已验证和无法使用的设备、硬件解码器说明、设备与摄像机报告 | -| [Drones and Wi‑Fi toys](docs/toys.md) | 玩具如何传输视频、FLOW-UFO、KY UFO 和 Jieli 无人机、遇到未知玩具时可以尝试什么、需要提交哪些信息 | +| [Drones and Wi‑Fi toys](docs/toys.md) | 玩具如何传输视频、FLOW-UFO、KY UFO、Jieli 和 Lewei 无人机、遇到未知玩具时可以尝试什么、需要提交哪些信息 | 更新历史即[已合并的 pull request](https://github.com/OpenIPC/decoder/pulls?q=is%3Apr+is%3Amerged+base%3Av2) 和 [v2 分支的提交](https://github.com/OpenIPC/decoder/commits/v2)。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 8c141c0..693de40 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.openipc.decoder" minSdk 21 targetSdk 36 - versionCode 50 - versionName "2.20.0" + versionCode 51 + versionName "2.21.0" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 8deb273..ca1dc0b 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1840,6 +1840,7 @@ private String slotTransport(int i, String url) { if (HttpMjpeg.matches(url)) return "HTTP"; if (FlowUfo.Url.matches(url)) return "FLOW-UFO UDP"; if (Jieli.Url.matches(url)) return "Jieli RTP"; + if (Lewei.Url.matches(url)) return "Lewei TCP"; Transport.Mode m = StreamProbe.probeMode(mTransports[i]); return "check over " + (m == Transport.Mode.MULTICAST ? "multicast" : m.name()); } diff --git a/android/app/src/main/java/com/openipc/decoder/Lewei.java b/android/app/src/main/java/com/openipc/decoder/Lewei.java new file mode 100644 index 0000000..6413c12 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/Lewei.java @@ -0,0 +1,491 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * Lewei.java — Lewei toy drones (vendor app WiFi UFO): H.264 over TCP port 7060 (#262) + * + */ + +package com.openipc.decoder; + +import android.util.Log; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.Locale; + +/** + * Toy drones with a Lewei camera (a VISUO, vendor app WiFi UFO, com.lewei.multiple.lewei) at + * 192.168.0.1 (#262). A PCAPdroid capture of the vendor app shows the session on TCP port + * 7060; pylwdrone (meekworth/pylwdrone, Apache-2.0, used as a description only) names the + * fields. Every message starts with a 46-byte header: "lewei_cmd", a zero byte, then nine + * little-endian 32-bit numbers: the command, an argument, one unused, the length of what + * follows, the stream type, two keys. The client sends command 2 once with the argument 1 (the + * high-definition stream); the toy answers with video at once, command 1 every second keeps it + * going (the toy answers with 64 bytes of its own), command 3 ends it. Video comes as command + * 257: 32 bytes of its own, little-endian (0-3 a flag, set on a frame with SPS and PPS, 4-7 the + * length of the frame, 8-15 a counter, the time in ms), then one H.264 frame in Annex B form. + * With stream type 1, as the capture has it, the camera has inverted one byte of the frame, + * at a place that follows from the length and the counter: see {@link #mungedByte}. Type 129, + * which needs tables, is not handled: the log says so and the picture will have errors. The NAL + * units go to the normal H.264 path as RTP. The vendor app also talks to TCP port 8060 (the + * time, a query) after the video has started, so the video does not need it, and sends flight + * packets over UDP: neither is done here, this class only asks for video. + * Pure Java apart from the log. + */ +final class Lewei { + private static final String TAG = "OpenIPCDecoder"; + + static final String SCHEME = "lewei://"; + static final String DEFAULT_HOST = "192.168.0.1"; + static final int PORT = 7060; + static final int HEADER = 46; + static final int VIDEO_HEADER = 32; + static final int CMD_HEARTBEAT = 1; + static final int CMD_START = 2; + static final int CMD_STOP = 3; + static final int CMD_VIDEO = 257; + static final int STREAM_PLAIN = 0; + static final int STREAM_ONE_BYTE = 1; + /** The largest message accepted; a longer length is taken for a damaged header. */ + static final int MAX_MESSAGE = 4 * 1024 * 1024; + static final long HEARTBEAT_MS = 1000; + /** No video for this long: the session ends and the next one starts over. */ + static final long QUIET_MS = 5000; + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TICK_MS = 200; + /** Messages logged whole from the start of a session, for reports from the field. */ + private static final int LOGGED_MESSAGES = 6; + + private static final byte[] MAGIC = {'l', 'e', 'w', 'e', 'i', '_', 'c', 'm', 'd'}; + private static final int CMD_AT = 10; + private static final int ARG_AT = 14; + private static final int LENGTH_AT = 22; + private static final int STREAM_TYPE_AT = 26; + /** Where the counter of a frame is, in the 32 bytes before it. */ + private static final int COUNTER_AT = 8; + + private Lewei() { + } + + /** lewei://host[:port]; the host defaults to 192.168.0.1, the port to 7060. */ + static final class Url { + String host = DEFAULT_HOST; + int port = PORT; + + static boolean matches(String url) { + return url != null && url.trim().toLowerCase(Locale.ROOT).startsWith(SCHEME); + } + + static Url parse(String url) throws IOException { + if (!matches(url)) throw new IOException("Not a lewei:// URL"); + String rest = url.trim().substring(SCHEME.length()); + for (int i = 0; i < rest.length(); i++) { + char ch = rest.charAt(i); + if (ch == '/' || ch == '?' || ch == '#') { + rest = rest.substring(0, i); + break; + } + } + Url u = new Url(); + String host = rest; + int colon = rest.lastIndexOf(':'); + if (rest.startsWith("[")) { + int close = rest.indexOf(']'); + if (close < 0) throw new IOException("Bad host in URL"); + host = rest.substring(1, close); + colon = rest.indexOf(':', close); + } else if (colon >= 0) { + host = rest.substring(0, colon); + } + if (colon >= 0) u.port = port(rest.substring(colon + 1)); + if (!host.isEmpty()) u.host = host; + for (int i = 0; i < u.host.length(); i++) { + char ch = u.host.charAt(i); + if (ch <= 0x20 || ch == 0x7F || ch == '@') throw new IOException("Bad host in URL"); + } + return u; + } + + private static int port(String s) throws IOException { + try { + int v = Integer.parseInt(s.trim()); + if (v >= 1 && v <= 65535) return v; + } catch (NumberFormatException e) { + // said below + } + throw new IOException("Bad port in URL: " + s); + } + + String endpoint() { + return (host.indexOf(':') >= 0 ? "[" + host + "]" : host) + ":" + port; + } + } + + /** A message to the toy: the header alone. */ + static byte[] command(int cmd) { + byte[] m = new byte[HEADER]; + System.arraycopy(MAGIC, 0, m, 0, MAGIC.length); + m[CMD_AT] = (byte) cmd; + if (cmd == CMD_START) m[ARG_AT] = 1; + return m; + } + + private static int le32(byte[] b, int at) { + return (b[at] & 0xFF) | (b[at + 1] & 0xFF) << 8 | (b[at + 2] & 0xFF) << 16 + | (b[at + 3] & 0xFF) << 24; + } + + private static long le64(byte[] b, int at) { + return (le32(b, at) & 0xFFFFFFFFL) | (long) le32(b, at + 4) << 32; + } + + /** Index of the byte the camera inverted in a frame of stream type 1. */ + static int mungedByte(long counter, int size) { + long s = size & 0xFFFFFFFFL; + // Only the low bits of the counter reach the result for any time a toy runs + long c = counter & Long.MAX_VALUE; + long v = (s & 1) == 0 ? (s + 1 + (s ^ c)) ^ s : ((s ^ c) + s) ^ s; + return (int) ((v & Long.MAX_VALUE) % s); + } + + /** + * Takes the bytes of the TCP stream in pieces of any size and gives whole messages. After + * damage it looks for the next "lewei_cmd". Pure Java, for the unit tests. + */ + static final class Reader { + private byte[] buf = new byte[256 * 1024]; + private int start; + private int end; + /** Bytes thrown away to find the next header. */ + long skipped; + /** The message of the last next(), valid until the following call. */ + int cmd; + int streamType; + int bodyAt; + int bodyLength; + + byte[] buffer() { + return buf; + } + + /** Room for at least one read; call before reading into buffer() at writeAt(). */ + int room() { + if (start > 0) { + System.arraycopy(buf, start, buf, 0, end - start); + end -= start; + start = 0; + } + if (end == buf.length) { + byte[] bigger = new byte[buf.length * 2]; + System.arraycopy(buf, 0, bigger, 0, end); + buf = bigger; + } + return buf.length - end; + } + + int writeAt() { + return end; + } + + void wrote(int n) { + end += n; + } + + /** The next whole message in the buffer, or false when more bytes are needed. */ + boolean next() { + while (end - start >= HEADER) { + if (!magicAt(start)) { + resync(); + continue; + } + int length = le32(buf, start + LENGTH_AT); + if (length < 0 || length > MAX_MESSAGE) { + start++; + skipped++; + resync(); + continue; + } + if (end - start < HEADER + length) return false; + cmd = le32(buf, start + CMD_AT); + streamType = le32(buf, start + STREAM_TYPE_AT); + bodyAt = start + HEADER; + bodyLength = length; + start += HEADER + length; + return true; + } + return false; + } + + private boolean magicAt(int at) { + for (int i = 0; i < MAGIC.length; i++) { + if (buf[at + i] != MAGIC[i]) return false; + } + return true; + } + + /** To the next possible header; a partial one at the end of the buffer stays. */ + private void resync() { + while (start < end) { + int n = Math.min(MAGIC.length, end - start); + boolean match = true; + for (int i = 0; i < n && match; i++) match = buf[start + i] == MAGIC[i]; + if (match) return; + start++; + skipped++; + } + } + + boolean isVideo() { + return cmd == CMD_VIDEO && bodyLength > VIDEO_HEADER; + } + + boolean isKeyFrame() { + return le32(buf, bodyAt) != 0; + } + + int timeMs() { + return le32(buf, bodyAt + COUNTER_AT); + } + + /** Undoes what the camera did to the frame, in place; false for an unknown type. */ + boolean restoreFrame() { + if (streamType == STREAM_PLAIN) return true; + if (streamType != STREAM_ONE_BYTE) return false; + int size = bodyLength - VIDEO_HEADER; + int i = mungedByte(le64(buf, bodyAt + COUNTER_AT), size); + buf[bodyAt + VIDEO_HEADER + i] ^= (byte) 0xFF; + return true; + } + } + + /** Gets RTP packets of a video stream. */ + interface RtpSink { + boolean onRtp(byte[] buf, int offset, int length); + } + + /** + * Cuts an Annex B frame into NAL units and hands them on as RTP (RFC 6184: single NAL + * units and FU-A), which is what the app's H.264 path takes. Pure Java, for the unit tests. + */ + static final class Packetizer { + static final int PT = Jieli.PT_H264; + private static final int RTP_HEADER = 12; + private static final int MAX_PAYLOAD = 1400; + private static final int NAL_FU_A = 28; + private final byte[] packet = new byte[RTP_HEADER + MAX_PAYLOAD]; + private int seq; + + /** Returns whether anything of the frame was accepted. */ + boolean frame(byte[] b, int from, int to, int timeMs, RtpSink sink) { + boolean used = false; + int nal = nextStart(b, from, to); + while (nal >= 0) { + int begin = nal + startLength(b, nal); + int next = nextStart(b, begin, to); + int end = next >= 0 ? next : to; + // Zero bytes before the next start code are not part of the unit + while (end > begin && b[end - 1] == 0) end--; + if (end > begin) used |= nal(b, begin, end, timeMs, next < 0, sink); + nal = next; + } + return used; + } + + private boolean nal(byte[] b, int from, int to, int timeMs, boolean last, RtpSink sink) { + int size = to - from; + if (size <= MAX_PAYLOAD) { + System.arraycopy(b, from, packet, RTP_HEADER, size); + return send(RTP_HEADER + size, timeMs, last, sink); + } + boolean used = false; + int header = b[from] & 0xFF; + int pos = from + 1; + boolean first = true; + while (pos < to) { + int n = Math.min(MAX_PAYLOAD - 2, to - pos); + boolean end = pos + n == to; + packet[RTP_HEADER] = (byte) (header & 0xE0 | NAL_FU_A); + packet[RTP_HEADER + 1] = (byte) (header & 0x1F | (first ? 0x80 : 0) + | (end ? 0x40 : 0)); + System.arraycopy(b, pos, packet, RTP_HEADER + 2, n); + used |= send(RTP_HEADER + 2 + n, timeMs, end && last, sink); + pos += n; + first = false; + } + return used; + } + + private boolean send(int length, int timeMs, boolean marker, RtpSink sink) { + long ts = (timeMs & 0xFFFFFFFFL) * 90; + packet[0] = (byte) 0x80; + packet[1] = (byte) (PT | (marker ? 0x80 : 0)); + packet[2] = (byte) (seq >> 8); + packet[3] = (byte) seq; + packet[4] = (byte) (ts >> 24); + packet[5] = (byte) (ts >> 16); + packet[6] = (byte) (ts >> 8); + packet[7] = (byte) ts; + seq = (seq + 1) & 0xFFFF; + return sink.onRtp(packet, 0, length); + } + + /** Position of the next start code in the range, or -1. */ + private static int nextStart(byte[] b, int from, int to) { + for (int i = from; i + 3 <= to; i++) { + if (b[i] == 0 && b[i + 1] == 0 && b[i + 2] == 1) { + return i > from && b[i - 1] == 0 ? i - 1 : i; + } + } + return -1; + } + + private static int startLength(byte[] b, int at) { + return b[at + 2] == 1 ? 3 : 4; + } + } + + /** Whether the session should go on. */ + interface KeepGoing { + boolean check(); + } + + /** What a session reports, on the thread of run(). */ + interface Listener { + void onState(boolean receiving, String text); + + /** One RTP packet of H.264, valid during the call; returns whether it was taken. */ + boolean onRtp(byte[] buf, int offset, int length); + } + + /** + * One session on the calling thread until {@code keepGoing} fails or close(). A lost + * connection or a toy that stopped sending ends run() with an exception: the session + * starts again. + */ + static final class Client implements Closeable { + private final String tag; + private final Url url; + private final Jieli.TcpSetup setup; + private final Socket socket = new Socket(); + private volatile boolean closed; + /** The stream was asked for on this connection. */ + volatile boolean streamRequested; + + Client(String tag, Url url, Jieli.TcpSetup setup) { + this.tag = tag; + this.url = url; + this.setup = setup; + } + + void run(KeepGoing keepGoing, Listener listener) throws IOException { + InetAddress target = InetAddress.getByName(url.host); + String bound = setup.prepare(socket, target); + listener.onState(false, "Waiting for the toy: connecting to " + url.endpoint()); + try { + socket.connect(new InetSocketAddress(target, url.port), CONNECT_TIMEOUT_MS); + } catch (IOException e) { + throw closed ? e : new IOException("Cannot reach the toy's video port " + + url.endpoint() + ": " + e.getMessage(), e); + } + Log.i(TAG, tag + ": Lewei: connected to " + url.endpoint() + " (" + bound + ")"); + socket.setTcpNoDelay(true); + socket.setSoTimeout(READ_TICK_MS); + InputStream in = socket.getInputStream(); + OutputStream out = socket.getOutputStream(); + out.write(command(CMD_START)); + streamRequested = true; + listener.onState(false, "Waiting for the toy: asked for video on " + url.endpoint()); + + Reader reader = new Reader(); + Packetizer packetizer = new Packetizer(); + RtpSink sink = listener::onRtp; + long now = nowMs(); + long nextHeartbeat = now + HEARTBEAT_MS; + long lastVideo = now; + boolean receiving = false; + int logged = 0; + int warnedType = STREAM_PLAIN; + long skippedLogged = 0; + try { + while (keepGoing.check() && !closed) { + try { + int room = reader.room(); + int n = in.read(reader.buffer(), reader.writeAt(), room); + if (n < 0) throw new IOException("The toy closed the connection"); + reader.wrote(n); + } catch (SocketTimeoutException e) { + // Time to look at the clock + } + while (reader.next()) { + boolean video = reader.isVideo(); + if (logged < LOGGED_MESSAGES) { + logged++; + Log.i(TAG, tag + ": Lewei: message " + describe(reader)); + } + if (!video) continue; + if (!reader.restoreFrame() && reader.streamType != warnedType) { + warnedType = reader.streamType; + Log.w(TAG, tag + ": Lewei: stream type " + warnedType + + " is not handled, the picture will have errors"); + } + if (packetizer.frame(reader.buffer(), reader.bodyAt + VIDEO_HEADER, + reader.bodyAt + reader.bodyLength, reader.timeMs(), sink)) { + lastVideo = nowMs(); + if (!receiving) { + receiving = true; + listener.onState(true, null); + } + } + } + if (reader.skipped != skippedLogged) { + skippedLogged = reader.skipped; + Log.w(TAG, tag + ": Lewei: " + skippedLogged + " bytes skipped in all"); + } + now = nowMs(); + if (now >= nextHeartbeat) { + out.write(command(CMD_HEARTBEAT)); + nextHeartbeat = now + HEARTBEAT_MS; + } + if (now - lastVideo >= QUIET_MS) { + throw new IOException("No video from the toy for " + + QUIET_MS / 1000 + " s"); + } + } + out.write(command(CMD_STOP)); + } catch (IOException e) { + if (!closed && keepGoing.check()) throw e; + } + } + + private static String describe(Reader r) { + StringBuilder sb = new StringBuilder("cmd ").append(r.cmd).append(" type ") + .append(r.streamType).append(", ").append(r.bodyLength).append(" bytes:"); + int n = Math.min(r.bodyLength, r.isVideo() ? VIDEO_HEADER + 8 : 64); + for (int i = 0; i < n; i++) { + sb.append(String.format(Locale.ROOT, " %02x", + r.buffer()[r.bodyAt + i] & 0xFF)); + } + return sb.toString(); + } + + private static long nowMs() { + return System.nanoTime() / 1_000_000; + } + + @Override + public void close() { + closed = true; + try { + socket.close(); + } catch (IOException e) { + // Closed is closed + } + } + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index ac87a5f..b82a888 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -110,6 +110,8 @@ default void onDetail(String text) {} private volatile FlowUfo.Client ufo; /** Client of a jieli:// slot (#187), else null: it asks for the stream again on its own. */ private volatile Jieli.Client jieli; + /** Client of a lewei:// slot (#262), else null: it ends the session on its own when silent. */ + private volatile Lewei.Client lewei; /** Heartbeat of the RTSP connection now (#187), else null; counts of the earlier ones. */ private volatile UdpHeartbeat heartbeat; private volatile int heartbeatsBefore; @@ -238,6 +240,7 @@ private void closeSocketLocked() { if (listener != null) listener.close(); if (ufo != null) ufo.close(); if (jieli != null) jieli.close(); + if (lewei != null) lewei.close(); if (heartbeat != null) heartbeat.close(); if (udp != null) { udp.close(); @@ -311,7 +314,7 @@ private void watchdogLoop(int gen) { } } } - if (active && !listening && ufo == null && jieli == null + if (active && !listening && ufo == null && jieli == null && lewei == null && System.nanoTime() / 1_000_000 - lastPacketMs > watchdogMs) { Log.w(TAG, tag + ": stream is inactive, reconnecting"); active = false; @@ -344,6 +347,10 @@ private boolean connect(int gen) throws IOException { rotate = 0; return flowUfo(gen, FlowUfo.Url.parse(url)); } + if (Lewei.Url.matches(url)) { + rotate = 0; + return lewei(gen, Lewei.Url.parse(url)); + } if (Jieli.Url.matches(url)) { Jieli.Url j = Jieli.Url.parse(url); rotate = j.rotate; @@ -936,6 +943,64 @@ private static Sdp jieliSdp(int pt) { return sdp; } + /** + * A lewei:// slot (#262): H.264 over the toy's TCP port, handed on as RTP to the normal + * path. A session that ends after the stream was asked for counts like a PLAY that worked, + * so the next attempt comes soon, not after a growing back-off. + */ + private boolean lewei(int gen, Lewei.Url u) throws IOException { + Lewei.Client c = new Lewei.Client(tag, u, NetInfo::bindToNetworkOf); + synchronized (lock) { + if (gen != generation) return false; + lewei = c; + keepalive = null; + lastPacketMs = System.nanoTime() / 1_000_000; + streamBytes = 0; + bitrateMarkNs = System.nanoTime(); + active = true; + } + try { + callback.onSdp(jieliSdp(Lewei.Packetizer.PT)); + final boolean[] first = {true}; + try { + c.run(() -> active && gen == generation, new Lewei.Listener() { + @Override + public void onState(boolean receiving, String text) { + if (gen != generation) return; + if (receiving) inUse = "Lewei TCP"; + callback.onDetail(text); + } + + @Override + public boolean onRtp(byte[] buf, int offset, int length) { + if (gen != generation) return false; + streamBytes += length; + if (!callback.onPacket(CHANNEL_VIDEO, buf, offset, length)) return false; + lastPacketMs = System.nanoTime() / 1_000_000; + if (first[0]) { + first[0] = false; + status(gen, "connected"); + } + return true; + } + }); + } catch (IOException e) { + if (!c.streamRequested || gen != generation) throw e; + Log.w(TAG, tag + ": Lewei: " + e.getMessage() + "; connecting again"); + callback.onFailure(e.getMessage()); + } + return true; + } finally { + synchronized (lock) { + if (lewei == c) { + lewei = null; + inUse = null; + } + } + c.close(); + } + } + private boolean listen(int gen, RtpListener.Url u) throws IOException { RtpListener l = new RtpListener(tag, u); synchronized (lock) { diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbes.java b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java index 536b39c..5fc5814 100644 --- a/android/app/src/main/java/com/openipc/decoder/StreamProbes.java +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java @@ -50,7 +50,7 @@ static String skipReason(String url, String defaultUrl, String screenUrl) { // A listening slot would take the port; the camera pushes to it, nothing to ask if (RtpListener.Url.matches(url)) return "UDP listen slot: not checked in single view"; // A toy drone sends to one client; a check would take the stream from the screen - if (FlowUfo.Url.matches(url) || Jieli.Url.matches(url)) { + if (FlowUfo.Url.matches(url) || Jieli.Url.matches(url) || Lewei.Url.matches(url)) { return "toy drone slot: not checked in single view"; } String e = endpoint(url); diff --git a/android/app/src/test/java/com/openipc/decoder/LeweiTest.java b/android/app/src/test/java/com/openipc/decoder/LeweiTest.java new file mode 100644 index 0000000..8ff5435 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/LeweiTest.java @@ -0,0 +1,365 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * LeweiTest.java — lewei:// slots: framing, RTP of Annex B frames, the session against a fake toy + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +public class LeweiTest { + private static byte[] hex(String s) { + byte[] b = new byte[s.length() / 2]; + for (int i = 0; i < b.length; i++) { + b[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16); + } + return b; + } + + private static byte[] message(int cmd, byte[] body) { + byte[] m = Lewei.command(cmd); + m = Arrays.copyOf(m, Lewei.HEADER + body.length); + m[11] = (byte) (cmd >> 8); + m[14] = 0; + m[22] = (byte) body.length; + m[23] = (byte) (body.length >> 8); + m[24] = (byte) (body.length >> 16); + System.arraycopy(body, 0, m, Lewei.HEADER, body.length); + return m; + } + + private static byte[] video(boolean key, int timeMs, byte[] annexB) { + byte[] body = new byte[Lewei.VIDEO_HEADER + annexB.length]; + body[0] = (byte) (key ? 1 : 0); + body[4] = (byte) annexB.length; + body[5] = (byte) (annexB.length >> 8); + body[8] = (byte) timeMs; + body[9] = (byte) (timeMs >> 8); + body[10] = (byte) (timeMs >> 16); + System.arraycopy(annexB, 0, body, Lewei.VIDEO_HEADER, annexB.length); + return message(Lewei.CMD_VIDEO, body); + } + + private static void feed(Lewei.Reader r, byte[] b, int from, int to) { + while (from < to) { + int n = Math.min(r.room(), to - from); + System.arraycopy(b, from, r.buffer(), r.writeAt(), n); + r.wrote(n); + from += n; + } + } + + @Test + public void urlDefaultsAndPort() throws IOException { + assertTrue(Lewei.Url.matches(" LEWEI://192.168.0.1")); + assertFalse(Lewei.Url.matches("rtsp://192.168.0.1")); + Lewei.Url d = Lewei.Url.parse("lewei://"); + assertEquals("192.168.0.1", d.host); + assertEquals(7060, d.port); + Lewei.Url u = Lewei.Url.parse("lewei://10.0.2.2:17060/x?y=1"); + assertEquals("10.0.2.2", u.host); + assertEquals(17060, u.port); + assertEquals("10.0.2.2:17060", u.endpoint()); + } + + @Test + public void badUrls() { + for (String s : new String[]{"lewei://h:0", "lewei://h:x", "lewei://a b", "lewei://[::1"}) { + try { + Lewei.Url.parse(s); + fail(s); + } catch (IOException e) { + // expected + } + } + } + + /** The bytes the vendor app sends, from the capture in #262. */ + @Test + public void commandsAreThoseOfTheVendorApp() { + String zeros = "00000000000000000000000000000000000000000000000000000000000000"; + assertArrayEquals(hex("6c657765695f636d64000200000001" + zeros), Lewei.command(2)); + assertArrayEquals(hex("6c657765695f636d64000100000000" + zeros), Lewei.command(1)); + assertArrayEquals(hex("6c657765695f636d64000300000000" + zeros), Lewei.command(3)); + } + + @Test + public void readerTakesAVideoMessageInSmallPieces() { + byte[] m = video(true, 94950, new byte[]{0, 0, 0, 1, 0x27, 1, 2}); + Lewei.Reader r = new Lewei.Reader(); + for (int i = 0; i < m.length - 1; i++) { + feed(r, m, i, i + 1); + assertFalse(r.next()); + } + feed(r, m, m.length - 1, m.length); + assertTrue(r.next()); + assertTrue(r.isVideo()); + assertTrue(r.isKeyFrame()); + assertEquals(94950, r.timeMs()); + assertEquals(Lewei.VIDEO_HEADER + 7, r.bodyLength); + assertFalse(r.next()); + } + + /** Length, counter and the place of the inverted byte of frames in the capture of #262. */ + @Test + public void mungedByteOfCapturedFrames() { + assertEquals(6297, Lewei.mungedByte(94900, 10301)); + assertEquals(44633, Lewei.mungedByte(94950, 76625)); + assertEquals(6671, Lewei.mungedByte(95000, 10270)); + } + + @Test + public void streamTypeOneRestoresTheInvertedByte() { + byte[] annexB = new byte[64]; + annexB[2] = 1; + annexB[3] = 0x21; + int i = Lewei.mungedByte(5000, annexB.length); + annexB[i] = (byte) 0xA5; + byte[] m = video(false, 5000, annexB); + m[26] = Lewei.STREAM_ONE_BYTE; + m[Lewei.HEADER + Lewei.VIDEO_HEADER + i] = (byte) ~0xA5; + Lewei.Reader r = new Lewei.Reader(); + feed(r, m, 0, m.length); + assertTrue(r.next()); + assertTrue(r.restoreFrame()); + assertEquals((byte) 0xA5, r.buffer()[r.bodyAt + Lewei.VIDEO_HEADER + i]); + } + + @Test + public void plainStreamIsLeftAloneAndAnUnknownTypeIsSaid() { + byte[] annexB = {0, 0, 1, 0x21, 1, 2, 3, 4}; + byte[] m = video(false, 5000, annexB); + Lewei.Reader r = new Lewei.Reader(); + feed(r, m, 0, m.length); + assertTrue(r.next()); + assertTrue(r.restoreFrame()); + assertArrayEquals(annexB, Arrays.copyOfRange(r.buffer(), + r.bodyAt + Lewei.VIDEO_HEADER, r.bodyAt + r.bodyLength)); + m[26] = (byte) 129; + feed(r, m, 0, m.length); + assertTrue(r.next()); + assertFalse(r.restoreFrame()); + } + + @Test + public void heartbeatReplyIsNotVideo() { + byte[] m = message(Lewei.CMD_HEARTBEAT, new byte[64]); + Lewei.Reader r = new Lewei.Reader(); + feed(r, m, 0, m.length); + assertTrue(r.next()); + assertEquals(1, r.cmd); + assertFalse(r.isVideo()); + } + + @Test + public void readerFindsTheNextHeaderAfterDamage() { + byte[] good = video(false, 1, new byte[]{0, 0, 1, 0x21}); + byte[] tooLong = message(Lewei.CMD_VIDEO, new byte[0]); + tooLong[25] = 0x7F; + ByteArrayOutputStream s = new ByteArrayOutputStream(); + s.write(new byte[]{1, 2, 'l', 'e', 'w', 3}, 0, 6); + s.write(tooLong, 0, tooLong.length); + s.write(good, 0, good.length); + byte[] b = s.toByteArray(); + Lewei.Reader r = new Lewei.Reader(); + feed(r, b, 0, b.length); + assertTrue(r.next()); + assertTrue(r.isVideo()); + assertEquals(Lewei.VIDEO_HEADER + 4, r.bodyLength); + assertEquals(6 + tooLong.length, r.skipped); + } + + @Test + public void largeMessageGrowsTheBuffer() { + byte[] big = new byte[1024 * 1024]; + big[2] = 1; + byte[] m = video(true, 5, big); + m[24] = (byte) ((big.length + Lewei.VIDEO_HEADER) >> 16); + Lewei.Reader r = new Lewei.Reader(); + feed(r, m, 0, m.length); + assertTrue(r.next()); + assertEquals(big.length + Lewei.VIDEO_HEADER, r.bodyLength); + } + + /** SPS, PPS and an IDR too long for one packet, as the toy sends them, through the app's path. */ + @Test + public void frameComesOutOfTheNalAssemblerUnchanged() { + byte[] sps = {0x27, 0x4D, (byte) 0xE0, 0x28}; + byte[] pps = {0x28, (byte) 0xEE, 0x0D}; + byte[] idr = new byte[5000]; + Arrays.fill(idr, (byte) 0x55); + idr[0] = 0x25; + ByteArrayOutputStream f = new ByteArrayOutputStream(); + byte[] sc4 = {0, 0, 0, 1}; + f.write(sc4, 0, 4); + f.write(sps, 0, sps.length); + f.write(sc4, 0, 4); + f.write(pps, 0, pps.length); + f.write(new byte[]{0, 0, 1}, 0, 3); + f.write(idr, 0, idr.length); + byte[] b = f.toByteArray(); + + List out = new ArrayList<>(); + NalAssembler a = new NalAssembler(1024 * 1024, () -> { }, new FramePool(4)); + NalAssembler.Sink sink = nal -> out.add(Arrays.copyOf(nal.data(), nal.length())); + final int[] packets = {0}; + final int[] markers = {0}; + new Lewei.Packetizer().frame(b, 0, b.length, 95000, (buf, off, len) -> { + packets[0]++; + if ((buf[off + 1] & 0x80) != 0) markers[0]++; + assertEquals(96, buf[off + 1] & 0x7F); + assertTrue(len <= 1412); + return a.assemble(buf, off, len, false, sink); + }); + assertEquals(1, markers[0]); + assertTrue(packets[0] > 4); + assertEquals(3, out.size()); + assertTrue(endsWith(out.get(0), sps)); + assertTrue(endsWith(out.get(1), pps)); + assertTrue(endsWith(out.get(2), idr)); + } + + private static boolean endsWith(byte[] b, byte[] tail) { + return b.length >= tail.length && Arrays.equals( + Arrays.copyOfRange(b, b.length - tail.length, b.length), tail); + } + + @Test + public void rtpTimeIs90kHzAndSequenceRuns() { + byte[] f = {0, 0, 1, 0x21, 1, 0, 0, 1, 0x21, 2}; + List p = new ArrayList<>(); + new Lewei.Packetizer().frame(f, 0, f.length, 1000, (buf, off, len) -> { + p.add(Arrays.copyOfRange(buf, off, off + len)); + return true; + }); + assertEquals(2, p.size()); + assertEquals(90000, (p.get(0)[5] & 0xFF) << 16 | (p.get(0)[6] & 0xFF) << 8 + | (p.get(0)[7] & 0xFF)); + assertEquals(1, p.get(1)[3] - p.get(0)[3]); + assertEquals(0, p.get(0)[1] & 0x80); + assertEquals(0x80, p.get(1)[1] & 0x80); + } + + /** The session of the capture: start, video at once, heartbeats, stop at the end. */ + @Test(timeout = 15000) + public void sessionAgainstAFakeToy() throws Exception { + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + AtomicInteger starts = new AtomicInteger(); + AtomicInteger beats = new AtomicInteger(); + AtomicInteger stops = new AtomicInteger(); + CountDownLatch beat = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + AtomicReference failed = new AtomicReference<>(); + Thread toy = new Thread(() -> { + try (Socket s = server.accept()) { + DataInputStream in = new DataInputStream(s.getInputStream()); + OutputStream out = s.getOutputStream(); + byte[] m = new byte[Lewei.HEADER]; + while (true) { + in.readFully(m); + if (m[10] == Lewei.CMD_START) { + starts.incrementAndGet(); + out.write(video(true, 1000, new byte[]{0, 0, 1, 0x27, 1})); + } else if (m[10] == Lewei.CMD_HEARTBEAT) { + beats.incrementAndGet(); + out.write(message(Lewei.CMD_HEARTBEAT, new byte[64])); + out.write(video(false, 1050, new byte[]{0, 0, 1, 0x21, 1})); + beat.countDown(); + } else if (m[10] == Lewei.CMD_STOP) { + stops.incrementAndGet(); + return; + } + } + } catch (IOException | RuntimeException e) { + failed.set(e); + } finally { + done.countDown(); + } + }); + toy.setDaemon(true); + toy.start(); + + Lewei.Url u = Lewei.Url.parse("lewei://127.0.0.1:" + server.getLocalPort()); + AtomicInteger rtp = new AtomicInteger(); + List states = new ArrayList<>(); + try (Lewei.Client c = new Lewei.Client("t", u, (sock, target) -> "default")) { + c.run(() -> beat.getCount() > 0, new Lewei.Listener() { + @Override + public void onState(boolean receiving, String text) { + states.add(receiving); + } + + @Override + public boolean onRtp(byte[] buf, int offset, int length) { + rtp.incrementAndGet(); + return true; + } + }); + assertTrue(c.streamRequested); + } + assertTrue(done.await(5, TimeUnit.SECONDS)); + if (failed.get() != null) throw new AssertionError(failed.get()); + assertEquals(1, starts.get()); + assertTrue(beats.get() >= 1); + assertEquals(1, stops.get()); + assertTrue(rtp.get() >= 1); + assertTrue(states.contains(true)); + } + } + + @Test(timeout = 15000) + public void toyThatClosesEndsTheSession() throws Exception { + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + Thread toy = new Thread(() -> { + try (Socket s = server.accept()) { + s.getInputStream().read(new byte[Lewei.HEADER]); + } catch (IOException e) { + // the test sees it + } + }); + toy.setDaemon(true); + toy.start(); + Lewei.Url u = Lewei.Url.parse("lewei://127.0.0.1:" + server.getLocalPort()); + try (Lewei.Client c = new Lewei.Client("t", u, (sock, target) -> "default")) { + try { + c.run(() -> true, new Lewei.Listener() { + @Override + public void onState(boolean receiving, String text) { + } + + @Override + public boolean onRtp(byte[] buf, int offset, int length) { + return true; + } + }); + fail("run() must end with an exception"); + } catch (IOException e) { + assertTrue(e.getMessage(), c.streamRequested); + } + } + } + } +} diff --git a/docs/toys.md b/docs/toys.md index 29e33e2..b418cf0 100644 --- a/docs/toys.md +++ b/docs/toys.md @@ -6,6 +6,7 @@ - [FLOW-UFO](#flow-ufo) - [KY UFO RTSP toys](#ky-ufo-rtsp-toys) - [HF / Jieli toys](#hf--jieli-toys) +- [Lewei toys](#lewei-toys) - [Credits](#credits) - [An unknown toy](#an-unknown-toy) - [What to send in a report](#what-to-send-in-a-report) @@ -45,6 +46,7 @@ The Wi‑Fi name and the address the phone gets usually tell the family. The toy | `FLOW-UFO-xxxxxx`, `FLOW_xxxxxx` | `192.168.169.x` | FLOW-UFO (BL60x camera; turbodrone calls it `wifi_uav`) | `flowufo://192.168.169.1` (UDP 8800 and 8801) | works since 2.15, checked with a D16 ([#187](https://github.com/OpenIPC/decoder/issues/187)) | | `FLY-UFO-xxxxxx` and other cooingdv toys (KY UFO, RC UFO apps) | `192.168.1.x` | KY UFO RTSP | `rtsp://192.168.1.1:7070/webcam` (with the heartbeat to UDP 7099) | works since 2.15, checked with a K200; the picture is turned upright ([#187](https://github.com/OpenIPC/decoder/issues/187), [#200](https://github.com/OpenIPC/decoder/issues/200)) | | `HF_xxxxxxxx` | `192.168.80.x` | Jieli / HF (Jieli AC79 camera, vendor app *DVRunning 2*) | `jieli://192.168.80.1` (CTP on TCP 3333, video to the phone's UDP 2224 or 6666) | works since 2.16, checked with a YL3 ([#187](https://github.com/OpenIPC/decoder/issues/187)) | +| not known yet (a VISUO drone, vendor app *WiFi UFO*) | `192.168.0.x` | Lewei | `lewei://192.168.0.1` (H.264 on TCP 7060) | since 2.21.0, not yet tried on a toy ([#262](https://github.com/OpenIPC/decoder/issues/262)) | When a `flowufo://` or `jieli://` slot points at the phone itself, or at the network of another family, its status says so (for example *192.168.1.100 is this phone; the toy is usually 192.168.1.1*, *This network looks like a KY UFO RTSP toy: try rtsp://192.168.1.1:7070/webcam* or, on a `192.168.80.x` network, *try jieli://192.168.80.1*). @@ -128,8 +130,30 @@ Status texts while it connects: *Connecting to the toy (CTP 192.168.80.1:3333)*, To use it: join the `HF_…` Wi‑Fi, [stay connected](#connecting), close the vendor app, put `jieli://192.168.80.1` into a slot. Wait about 30 s and [send a report](#what-to-send-in-a-report) in [#187](https://github.com/OpenIPC/decoder/issues/187), with or without a picture: `/stats.json` and `/log` tell what the toy really sends. +## Lewei toys + +| | | +|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| Wi‑Fi name | not known yet | +| Camera | Lewei; vendor app *WiFi UFO* (`com.lewei.multiple.lewei`), seen on a VISUO drone | +| Toy address | `192.168.0.1`, TCP port 7060 | +| Slot URL | `lewei://192.168.0.1` (or `lewei://` alone) | +| Status | written from a capture of the vendor app and played from that capture; not yet tried on a toy ([#262](https://github.com/OpenIPC/decoder/issues/262)) | + +What the app does, as a capture of the vendor app shows it; [pylwdrone](https://github.com/meekworth/pylwdrone) (Apache-2.0) names the fields: + +- It connects to TCP port 7060. Every message starts with a 46-byte header: `lewei_cmd`, a zero byte and nine little-endian 32-bit numbers (the command, an argument, one unused, the length of what follows, the stream type, two keys). +- Command 2 with the argument 1 asks for the high-definition stream, and the video starts at once. Command 1 every second keeps it going, command 3 ends it. +- The video is **H.264** in Annex B form, one frame in each message of command 257, behind 32 bytes of its own (a flag, the length of the frame, a counter in ms). The tested drone sends 1280×720 at 20 fps, about 2.8 Mbit/s. +- With stream type 1 the camera has **inverted one byte** of each frame, at a place that follows from the length and the counter; the app inverts it back. Without that, frames decode only partly. Type 129 is not handled: the log says so, and the picture will have errors. +- If no video comes for 5 s, the app connects again. +- **No flight command is ever sent.** The vendor app also talks to TCP port 8060 (the time, a query) and sends flight packets over UDP; the video does not need either, so the app does neither. +- The first messages of a session are written to the log in hex, for a [report](#what-to-send-in-a-report). + ## Credits +The Lewei fields and the inverted byte come from [pylwdrone](https://github.com/meekworth/pylwdrone) (Apache-2.0), used as a description. + What the app does for FLOW-UFO and KY UFO toys was checked against [turbodrone](https://github.com/marshallrichards/turbodrone) (Apache-2.0), which flies these drones from a computer: its `wifi_uav` backend is the FLOW-UFO protocol and its `cooingdv` backend the KY UFO RTSP toys. The default quality parameters, the second port 8801 and its timing, the camera status bytes, leaving lagging frames out of the acknowledgement, the raster tables of `dqt=raster` and the 7099 heartbeat come from its code and research notes; no code of it is used. For the HF / Jieli toys its research notes on the same Jieli SDK (`rxdrone`, `cooingdv_jieli`: RTP/JPEG to UDP 6666, audio on 1234, the stream ports) describe the video part; the control part follows a PCAPdroid capture of the vendor app posted in [#196](https://github.com/OpenIPC/decoder/issues/196). More open-source work on such toys is collected in [#196](https://github.com/OpenIPC/decoder/issues/196). ## An unknown toy @@ -163,9 +187,10 @@ Do not send the vendor app itself or parts of it. The capture contains only the The drones a tester checked in [#187](https://github.com/OpenIPC/decoder/issues/187): -| Toy | Wi‑Fi | Family | URL | Status | -|-----------|-------------|-----------------------------|----------------------------------|-------------------------------------------------------------------------------------------------------------| -| D16 | `FLOW_…` | FLOW-UFO, WIFI UAV app | `flowufo://192.168.169.1:8800` | video works | -| K200 | `FLY-UFO-…` | KY UFO, RTSP | `rtsp://192.168.1.1:7070/webcam` | video works; the sideways picture is turned upright ([#200](https://github.com/OpenIPC/decoder/issues/200)) | -| Drone YL3 | `HF_…` | Jieli AC79, DVRunning 2 app | `jieli://192.168.80.1` | video works; the sideways picture is turned upright; picture quality depends on the toy | +| Toy | Wi‑Fi | Family | URL | Status | +|-----------|---------------|-----------------------------|----------------------------------|-------------------------------------------------------------------------------------------------------------| +| D16 | `FLOW_…` | FLOW-UFO, WIFI UAV app | `flowufo://192.168.169.1:8800` | video works | +| K200 | `FLY-UFO-…` | KY UFO, RTSP | `rtsp://192.168.1.1:7070/webcam` | video works; the sideways picture is turned upright ([#200](https://github.com/OpenIPC/decoder/issues/200)) | +| Drone YL3 | `HF_…` | Jieli AC79, DVRunning 2 app | `jieli://192.168.80.1` | video works; the sideways picture is turned upright; picture quality depends on the toy | +| VISUO | not known yet | Lewei, WiFi UFO app | `lewei://192.168.0.1` | not yet tried on the toy ([#262](https://github.com/OpenIPC/decoder/issues/262)) | diff --git a/docs/transports.md b/docs/transports.md index b785653..fc460c4 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -20,6 +20,7 @@ A slot holds one URL. Its scheme picks the path the video takes; for `rtsp://` t | `udp://`, `rtp://` | plain RTP pushed by the camera, no RTSP | yes | yes | no | | `http://` | MJPEG, `multipart/x-mixed-replace` | yes | yes | no | | `flowufo://` | toy drone protocol ([#187](https://github.com/OpenIPC/decoder/issues/187)) | yes | yes | no | +| `lewei://` | toy drone protocol ([#262](https://github.com/OpenIPC/decoder/issues/262)) | yes | yes | no | The transport in use is shown when a stream starts in the single view (`Connected • UDP • H.265 …`), on the [debug page](debug.md) and in `/stats.json` (`transport`; the slot's setting is `transport_mode`). After a fallback it reads, for example, `TCP (auto)`. @@ -105,6 +106,8 @@ For example Majestic's `http://user:password@/mjpeg`: a `multipart/x-mix `jieli://192.168.80.1` (or `jieli://` alone) plays toy drones with an `HF_xxxxxxxx` Wi‑Fi (Jieli AC79 camera): CTP commands on TCP 3333 ask for the stream, which arrives as JPEG frames behind a 20-byte header on the phone's UDP port 2224 (seen from a YL3) or as RTP/JPEG on UDP 6666; works with a YL3 since 2.16. Options: `?w=`, `?h=`, `?fps=`, `?rtp=`, `?format=0|1`, `?rotate=`. See [toys.md](toys.md#hf--jieli-toys). +`lewei://192.168.0.1` (or `lewei://` alone) plays toy drones with a Lewei camera (vendor app WiFi UFO): H.264 over the toy's TCP port 7060, since 2.21.0 ([#262](https://github.com/OpenIPC/decoder/issues/262)). Details in [toys.md](toys.md#lewei-toys). + RTSP slots on `rtsp://:7070/webcam` (KY UFO and other cooingdv toys) also send the toy's UDP heartbeat `01 01` to port 7099 once a second; `?heartbeat=` turns it on for other RTSP URLs, `?heartbeat=off` off. Their MJPEG frames are drawn turned 270° counter-clockwise (90° clockwise) (the K200's camera is mounted sideways, [#200](https://github.com/OpenIPC/decoder/issues/200)); `?rotate=0|90|180|270` (degrees counter-clockwise, any RTSP slot) overrides it, and the slot's **Turn** setting overrides both ([#201](https://github.com/OpenIPC/decoder/issues/201)) and is not sent to the camera either. See [toys.md](toys.md#ky-ufo-rtsp-toys). ## Codecs From ab8ed712f5c38a0b2596a399abf6570f0ba6dbb0 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 4 Nov 2026 10:07:45 +0000 Subject: [PATCH 2/5] fix: a damaged video length is seen early; a reconnecting Lewei slot shows buffering (Qodo on #267) --- .../main/java/com/openipc/decoder/Lewei.java | 12 ++++++++++++ .../java/com/openipc/decoder/RtspSession.java | 7 ++++++- .../java/com/openipc/decoder/LeweiTest.java | 18 ++++++++++++++++++ docs/toys.md | 2 +- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Lewei.java b/android/app/src/main/java/com/openipc/decoder/Lewei.java index 6413c12..e20d853 100644 --- a/android/app/src/main/java/com/openipc/decoder/Lewei.java +++ b/android/app/src/main/java/com/openipc/decoder/Lewei.java @@ -67,6 +67,8 @@ final class Lewei { private static final int ARG_AT = 14; private static final int LENGTH_AT = 22; private static final int STREAM_TYPE_AT = 26; + /** Where the length of a frame is, in the 32 bytes before it. */ + private static final int FRAME_LENGTH_AT = 4; /** Where the counter of a frame is, in the 32 bytes before it. */ private static final int COUNTER_AT = 8; @@ -211,6 +213,16 @@ boolean next() { resync(); continue; } + // A damaged length would hold back the good messages behind it: a video + // message says its length twice + if (le32(buf, start + CMD_AT) == CMD_VIDEO && length > VIDEO_HEADER + && end - start >= HEADER + FRAME_LENGTH_AT + 4 + && le32(buf, start + HEADER + FRAME_LENGTH_AT) != length - VIDEO_HEADER) { + start++; + skipped++; + resync(); + continue; + } if (end - start < HEADER + length) return false; cmd = le32(buf, start + CMD_AT); streamType = le32(buf, start + STREAM_TYPE_AT); diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index b82a888..a0c7db1 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -967,7 +967,12 @@ private boolean lewei(int gen, Lewei.Url u) throws IOException { @Override public void onState(boolean receiving, String text) { if (gen != generation) return; - if (receiving) inUse = "Lewei TCP"; + if (receiving) { + inUse = "Lewei TCP"; + } else if (c.streamRequested) { + // After a lost connection: the slot waits again (Qodo on #267) + status(gen, "buffering"); + } callback.onDetail(text); } diff --git a/android/app/src/test/java/com/openipc/decoder/LeweiTest.java b/android/app/src/test/java/com/openipc/decoder/LeweiTest.java index 8ff5435..3e3c05e 100644 --- a/android/app/src/test/java/com/openipc/decoder/LeweiTest.java +++ b/android/app/src/test/java/com/openipc/decoder/LeweiTest.java @@ -56,6 +56,7 @@ private static byte[] video(boolean key, int timeMs, byte[] annexB) { body[0] = (byte) (key ? 1 : 0); body[4] = (byte) annexB.length; body[5] = (byte) (annexB.length >> 8); + body[6] = (byte) (annexB.length >> 16); body[8] = (byte) timeMs; body[9] = (byte) (timeMs >> 8); body[10] = (byte) (timeMs >> 16); @@ -192,6 +193,23 @@ public void readerFindsTheNextHeaderAfterDamage() { assertEquals(6 + tooLong.length, r.skipped); } + /** A plausible but wrong length must not hold back the frames behind it (Qodo on #267). */ + @Test + public void wrongVideoLengthIsSeenBeforeItsBodyArrives() { + byte[] damaged = video(false, 1, new byte[]{0, 0, 1, 0x21}); + damaged[23] = 0x10; + byte[] good = video(false, 2, new byte[]{0, 0, 1, 0x21, 1}); + ByteArrayOutputStream s = new ByteArrayOutputStream(); + s.write(damaged, 0, damaged.length); + s.write(good, 0, good.length); + byte[] b = s.toByteArray(); + Lewei.Reader r = new Lewei.Reader(); + feed(r, b, 0, b.length); + assertTrue(r.next()); + assertEquals(Lewei.VIDEO_HEADER + 5, r.bodyLength); + assertEquals(2, r.timeMs()); + } + @Test public void largeMessageGrowsTheBuffer() { byte[] big = new byte[1024 * 1024]; diff --git a/docs/toys.md b/docs/toys.md index b418cf0..d0f22c2 100644 --- a/docs/toys.md +++ b/docs/toys.md @@ -135,7 +135,7 @@ To use it: join the `HF_…` Wi‑Fi, [stay connected](#connecting), close the v | | | |-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| | Wi‑Fi name | not known yet | -| Camera | Lewei; vendor app *WiFi UFO* (`com.lewei.multiple.lewei`), seen on a VISUO drone | +| Camera | Lewei; vendor app *WiFi UFO* 4.5.x (`com.lewei.multiple.lewei`, Google Play), seen on a VISUO drone | | Toy address | `192.168.0.1`, TCP port 7060 | | Slot URL | `lewei://192.168.0.1` (or `lewei://` alone) | | Status | written from a capture of the vendor app and played from that capture; not yet tried on a toy ([#262](https://github.com/OpenIPC/decoder/issues/262)) | From 8a64898a79f6339f8ab8dd6efed2a8420f24380a Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 4 Nov 2026 10:11:40 +0000 Subject: [PATCH 3/5] docs: the changelog says Lewei is not yet tried on a toy (Qodo on #267) --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0808b0c..2a92ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,9 @@ were released. ## 2.21.0 — 2026-11-04 - Toy drones with a Lewei camera (vendor app WiFi UFO): the new slot URL `lewei://192.168.0.1` - plays their H.264 video from TCP port 7060 and restores the one byte the camera inverts in each - frame (#262). No flight command is sent. + asks for their H.264 video on TCP port 7060 and restores the one byte the camera inverts in each + frame (#262). Checked with a capture of the vendor app, not yet on a toy. No flight command is + sent. ## 2.20.0 — 2026-11-02 From dee9922922bc81b5c234f9c8e39234afe6d5f360 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Thu, 5 Nov 2026 09:35:42 +0000 Subject: [PATCH 4/5] fix: a stopped Lewei session does not reset a new stream (Qodo on #267) --- android/app/src/main/java/com/openipc/decoder/RtspSession.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index a0c7db1..1a7597c 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -960,6 +960,8 @@ private boolean lewei(int gen, Lewei.Url u) throws IOException { active = true; } try { + // Not under the lock: onSdp closes decoders, and stop() runs on the UI thread + if (gen != generation) return false; callback.onSdp(jieliSdp(Lewei.Packetizer.PT)); final boolean[] first = {true}; try { From e4cf651c1d159d30790ddc76de66937e15476263 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Thu, 5 Nov 2026 09:40:13 +0000 Subject: [PATCH 5/5] fix: text after a bracketed Lewei host is refused; the family table says Lewei works (Qodo on #267) --- .../src/main/java/com/openipc/decoder/Lewei.java | 6 +++++- .../test/java/com/openipc/decoder/LeweiTest.java | 13 ++++++++++++- docs/toys.md | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Lewei.java b/android/app/src/main/java/com/openipc/decoder/Lewei.java index e20d853..32e95cd 100644 --- a/android/app/src/main/java/com/openipc/decoder/Lewei.java +++ b/android/app/src/main/java/com/openipc/decoder/Lewei.java @@ -101,7 +101,11 @@ static Url parse(String url) throws IOException { int close = rest.indexOf(']'); if (close < 0) throw new IOException("Bad host in URL"); host = rest.substring(1, close); - colon = rest.indexOf(':', close); + String tail = rest.substring(close + 1); + if (!tail.isEmpty() && tail.charAt(0) != ':') { + throw new IOException("Bad host in URL"); + } + colon = tail.isEmpty() ? -1 : close + 1; } else if (colon >= 0) { host = rest.substring(0, colon); } diff --git a/android/app/src/test/java/com/openipc/decoder/LeweiTest.java b/android/app/src/test/java/com/openipc/decoder/LeweiTest.java index 3e3c05e..8628534 100644 --- a/android/app/src/test/java/com/openipc/decoder/LeweiTest.java +++ b/android/app/src/test/java/com/openipc/decoder/LeweiTest.java @@ -86,9 +86,20 @@ public void urlDefaultsAndPort() throws IOException { assertEquals("10.0.2.2:17060", u.endpoint()); } + @Test + public void bracketedHosts() throws IOException { + Lewei.Url plain = Lewei.Url.parse("lewei://[::1]"); + assertEquals("::1", plain.host); + assertEquals(Lewei.Url.parse("lewei://").port, plain.port); + Lewei.Url port = Lewei.Url.parse("lewei://[::1]:17060"); + assertEquals("::1", port.host); + assertEquals(17060, port.port); + } + @Test public void badUrls() { - for (String s : new String[]{"lewei://h:0", "lewei://h:x", "lewei://a b", "lewei://[::1"}) { + for (String s : new String[]{"lewei://h:0", "lewei://h:x", "lewei://a b", "lewei://[::1", + "lewei://[192.168.0.1]garbage:1234", "lewei://[::1]x"}) { try { Lewei.Url.parse(s); fail(s); diff --git a/docs/toys.md b/docs/toys.md index aea9c60..77a4798 100644 --- a/docs/toys.md +++ b/docs/toys.md @@ -46,7 +46,7 @@ The Wi‑Fi name and the address the phone gets usually tell the family. The toy | `FLOW-UFO-xxxxxx`, `FLOW_xxxxxx` | `192.168.169.x` | FLOW-UFO (BL60x camera; turbodrone calls it `wifi_uav`) | `flowufo://192.168.169.1` (UDP 8800 and 8801) | works since 2.15, checked with a D16 ([#187](https://github.com/OpenIPC/decoder/issues/187)) | | `FLY-UFO-xxxxxx` and other cooingdv toys (KY UFO, RC UFO apps) | `192.168.1.x` | KY UFO RTSP | `rtsp://192.168.1.1:7070/webcam` (with the heartbeat to UDP 7099) | works since 2.15, checked with a K200; the picture is turned upright ([#187](https://github.com/OpenIPC/decoder/issues/187), [#200](https://github.com/OpenIPC/decoder/issues/200)) | | `HF_xxxxxxxx` | `192.168.80.x` | Jieli / HF (Jieli AC79 camera, vendor app *DVRunning 2*) | `jieli://192.168.80.1` (CTP on TCP 3333, video to the phone's UDP 2224 or 6666) | works since 2.16, checked with a YL3 ([#187](https://github.com/OpenIPC/decoder/issues/187)) | -| `WiFi-720P-xxxxxx` (a VISUO drone, vendor app *WiFi UFO*) | `192.168.0.x` | Lewei | `lewei://192.168.0.1` (H.264 on TCP 7060) | since 2.21.0, not yet tried on a toy ([#262](https://github.com/OpenIPC/decoder/issues/262)) | +| `WiFi-720P-xxxxxx` (a VISUO drone, vendor app *WiFi UFO*) | `192.168.0.x` | Lewei | `lewei://192.168.0.1` (H.264 on TCP 7060) | works since 2.21.0, checked with a VISUO drone ([#262](https://github.com/OpenIPC/decoder/issues/262)) | When a `flowufo://` or `jieli://` slot points at the phone itself, or at the network of another family, its status says so (for example *192.168.1.100 is this phone; the toy is usually 192.168.1.1*, *This network looks like a KY UFO RTSP toy: try rtsp://192.168.1.1:7070/webcam* or, on a `192.168.80.x` network, *try jieli://192.168.80.1*).