diff --git a/android/app/build.gradle b/android/app/build.gradle index 53d4226c..a45a4439 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 40 - versionName "2.15" + versionCode 41 + versionName "2.16" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } diff --git a/android/app/src/main/java/com/openipc/decoder/Jieli.java b/android/app/src/main/java/com/openipc/decoder/Jieli.java index 6c3df414..a0c123ce 100644 --- a/android/app/src/main/java/com/openipc/decoder/Jieli.java +++ b/android/app/src/main/java/com/openipc/decoder/Jieli.java @@ -585,13 +585,14 @@ static String subnetHint(InetAddress target, List local) { // ====================== RT stream (UDP 2224) ====================== /** - * The toy's own video stream on UDP 2224 (#187), as a YL3 sent it: a 20-byte header, - * then a piece of a JPEG. Header, little-endian: byte 0 the type (2 for JPEG, bit 7 set - * on the last piece of a frame; not relied on), bytes 2–3 the length of this piece, 4–7 - * a packet counter, 8–11 the length of the JPEG, 12–15 the offset of this piece in it. The - * datagram can be longer than the piece: what follows is left over from earlier frames - * (taking it gave a broken lower half), so only the piece is used. Pure Java, for the - * unit tests. + * The toy's own video stream on UDP 2224 (#187), as a YL3 sent it: pieces of JPEG frames, + * each behind a 20-byte header, and a datagram can hold several pieces one after another + * (e.g. 20 + 2964 + 20 + 100 bytes for a 3064-byte frame). Header, little-endian: byte 0 + * the type (2 for JPEG, bit 7 set on the last piece of a frame; not relied on), bytes 2–3 + * the length of this piece, 4–7 a counter, 8–11 the length of the JPEG, 12–15 the offset + * of this piece in it. Taking a datagram as one piece put the second header into the JPEG + * (broken lower half, wrong colours); taking only the first piece lost every frame longer + * than one piece (the picture froze). Pure Java, for the unit tests. */ static final class RtStream { static final int HEADER = 20; @@ -600,10 +601,17 @@ static final class RtStream { static final int MAX_FRAME = 1 << 20; private byte[] frame = new byte[64 * 1024]; + private byte[] ready = new byte[64 * 1024]; private int size = -1; /** Bytes of the frame received so far: duplicates and overlaps count once. */ private final BitSet got = new BitSet(); private int filled; + /** Lowest and highest counter (bytes 4–7) of this frame's pieces; highest of the last done. */ + private int frameCounter; + private int frameMax; + private int doneCounter; + private boolean anyDone; + int late; int frames; int dropped; int otherTypes; @@ -621,41 +629,78 @@ private void drop(String reason, int n) { } /** - * Takes one datagram; returns the length of a JPEG now complete in {@link #frame()} - * (from 0, FFD8 to its last FFD9), or 0. + * Takes one datagram, all pieces in it; returns the length of a JPEG now complete in + * {@link #frame()} (from 0, FFD8 to its last FFD9), or 0. Should two frames end in one + * datagram, the later one is kept. */ int feed(byte[] b, int off, int len) { - if (len <= HEADER) return 0; + int done = 0; + int pos = off; + int end = off + len; + while (end - pos > HEADER) { + int length = (b[pos + 2] & 0xFF) | (b[pos + 3] & 0xFF) << 8; + // No length, or more than is left: the rest of the datagram is the piece + if (length == 0 || length > end - pos - HEADER) length = end - pos - HEADER; + int n = piece(b, pos, length); + if (n > 0) { + if (ready.length < n) ready = new byte[n]; + System.arraycopy(frame, 0, ready, 0, n); + done = n; + } + pos += HEADER + length; + } + return done; + } + + /** One piece of {@code length} bytes behind the header at {@code off}. */ + private int piece(byte[] b, int off, int length) { if ((b[off] & 0x7F) != TYPE_JPEG) { otherTypes++; return 0; } int total = le32(b, off + 8); int at = le32(b, off + 12); - int length = (b[off + 2] & 0xFF) | (b[off + 3] & 0xFF) << 8; - if (length == 0 || length > len - HEADER) length = len - HEADER; + int counter = le32(b, off + 4); + // A piece of an older frame (a late one, possibly behind a current piece in the same + // datagram) must not replace the frame being put together (Qodo on #224): one with + // an older counter and another frame length is late. Same length: a reordered piece + // of this frame. Whether the toy counts frames or packets is not known. + if ((size > 0 && total != size && counter - frameCounter < 0) + || (size < 0 && anyDone && counter - doneCounter <= 0)) { + late++; + return 0; + } if (total < 4 || total > MAX_FRAME || at < 0 || at >= total) { drop("bad header: length " + total + ", offset " + at, 0); return 0; } - int piece = Math.min(length, total - at); + int part = Math.min(length, total - at); // Pieces may come twice or out of order (Qodo on #215): a piece belongs to the frame // being put together if the length matches and it brings bytes not yet received; a // repeat is ignored. Anything else starts the next frame, and only then is an // unfinished one given up, even if its last piece came already. - if (size == total && got.get(at, at + piece).cardinality() == piece) { - if (same(b, off + HEADER, at, piece)) return 0; + if (size == total && got.get(at, at + part).cardinality() == part) { + if (same(b, off + HEADER, at, part)) return 0; startFrame(total); } else if (size != total) { startFrame(total); } - System.arraycopy(b, off + HEADER, frame, at, piece); - filled += piece - got.get(at, at + piece).cardinality(); - got.set(at, at + piece); + if (filled == 0) { + frameCounter = counter; + frameMax = counter; + } else { + if (counter - frameCounter < 0) frameCounter = counter; + if (counter - frameMax > 0) frameMax = counter; + } + System.arraycopy(b, off + HEADER, frame, at, part); + filled += part - got.get(at, at + part).cardinality(); + got.set(at, at + part); if (filled < size) return 0; int n = size; size = -1; got.clear(); + doneCounter = frameMax; + anyDone = true; int end = jpegEnd(frame, n); if (end == 0) { drop(n + " bytes, starts " + FlowUfo.hex(frame, 0, Math.min(n, 4)) + ", ends " @@ -667,8 +712,9 @@ int feed(byte[] b, int off, int len) { return end; } + /** The last complete JPEG returned by feed(). */ byte[] frame() { - return frame; + return ready; } private void startFrame(int total) { diff --git a/android/app/src/test/java/com/openipc/decoder/JieliTest.java b/android/app/src/test/java/com/openipc/decoder/JieliTest.java index 9d99f5e0..0576f43f 100644 --- a/android/app/src/test/java/com/openipc/decoder/JieliTest.java +++ b/android/app/src/test/java/com/openipc/decoder/JieliTest.java @@ -128,10 +128,64 @@ public void rtStreamFrameInPieces() { assertArrayEquals(j, Arrays.copyOf(st.frame(), n)); } + @Test + public void rtStreamTwoPiecesInOneDatagram() { + // As a YL3 sends a 3064-byte frame: 20 + 2964 + 20 + 100 = 3104 bytes in one datagram + Jieli.RtStream st = new Jieli.RtStream(); + byte[] j = jpeg(3064); + byte[] a = streamPacket(4, 3064, 0, Arrays.copyOfRange(j, 0, 2964), 0); + byte[] b = streamPacket(4, 3064, 2964, Arrays.copyOfRange(j, 2964, 3064), 0); + byte[] dgram = new byte[a.length + b.length]; + System.arraycopy(a, 0, dgram, 0, a.length); + System.arraycopy(b, 0, dgram, a.length, b.length); + assertEquals(3104, dgram.length); + int n = st.feed(dgram, 0, dgram.length); + assertEquals(3064, n); + assertArrayEquals(j, Arrays.copyOf(st.frame(), n)); + assertEquals(0, st.dropped); + // A datagram that ends one frame and holds a whole next one keeps the later one + byte[] k = jpeg(500); + byte[] c = streamPacket(5, 500, 0, k, 0); + byte[] two = new byte[c.length * 2]; + System.arraycopy(c, 0, two, 0, c.length); + byte[] l = jpeg(400); + byte[] d = streamPacket(6, 400, 0, l, 0); + System.arraycopy(d, 0, two, c.length, d.length); + assertEquals(400, st.feed(two, 0, c.length + d.length)); + assertArrayEquals(l, Arrays.copyOf(st.frame(), 400)); + assertEquals(3, st.frames); + } + + @Test + public void rtStreamIgnoresLatePiecesOfOlderFrames() { + // A current piece, then in the same datagram a late piece of an older frame, then the + // rest of the current frame (Qodo on #224) + Jieli.RtStream st = new Jieli.RtStream(); + byte[] old = jpeg(2000); + byte[] o = streamPacket(9, 2000, 0, old, 0); + assertEquals(2000, st.feed(o, 0, o.length)); + byte[] j = jpeg(6000); + byte[] a = streamPacket(10, 6000, 0, Arrays.copyOfRange(j, 0, 3000), 0); + byte[] late = streamPacket(8, 4000, 2000, new byte[2000], 0); + byte[] dgram = new byte[a.length + late.length]; + System.arraycopy(a, 0, dgram, 0, a.length); + System.arraycopy(late, 0, dgram, a.length, late.length); + assertEquals(0, st.feed(dgram, 0, dgram.length)); + byte[] b = streamPacket(10, 6000, 3000, Arrays.copyOfRange(j, 3000, 6000), 0); + int n = st.feed(b, 0, b.length); + assertEquals(6000, n); + assertArrayEquals(j, Arrays.copyOf(st.frame(), n)); + assertEquals(0, st.dropped); + assertEquals(1, st.late); + // A repeat of the frame just done is late as well + assertEquals(0, st.feed(b, 0, b.length)); + assertEquals(2, st.late); + } + @Test public void rtStreamUsesOnlyThePiece() { - // As test build 3 showed it: 2964 of 4600 bytes in a 4640-byte datagram whose rest is - // left over from an earlier frame (even with an FFD9); the rest comes at offset 2964 + // A piece shorter than its datagram: what follows is not part of this frame; the rest + // of the frame comes at offset 2964 Jieli.RtStream st = new Jieli.RtStream(); byte[] j = jpeg(4600); byte[] first = streamPacket(1, 4600, 0, Arrays.copyOfRange(j, 0, 2964), 0); diff --git a/docs/toys.md b/docs/toys.md index bc62e888..243ce2b8 100644 --- a/docs/toys.md +++ b/docs/toys.md @@ -44,7 +44,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) | **experimental** (2.15): YL3 video on UDP 2224 arrives and plays, picture quality still being checked ([#187](https://github.com/OpenIPC/decoder/issues/187)) | +| `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)) | 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*). @@ -95,20 +95,20 @@ Some of these toys use the FLOW-UFO protocol instead (the KY UFO app has both); ## HF / Jieli toys -| | | -|-------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Wi‑Fi name | `HF_xxxxxxxx` | -| Camera | Jieli AC79 (`AC79X_wifi_car_camera`); vendor app *DVRunning 2* (also *HF UFO*) | -| Toy address | `192.168.80.1`: control (CTP) on TCP port 3333; the video goes to UDP port 2224 (or 6666) **of the phone** | -| Slot URL | `jieli://192.168.80.1` (or `jieli://` alone) | -| Status | **experimental** since 2.15: the control part follows a capture of the vendor app; YL3 video arrives and plays, the picture quality is still being checked in [#187](https://github.com/OpenIPC/decoder/issues/187) | +| | | +|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| Wi‑Fi name | `HF_xxxxxxxx` | +| Camera | Jieli AC79 (`AC79X_wifi_car_camera`); vendor app *DVRunning 2* (also *HF UFO*) | +| Toy address | `192.168.80.1`: control (CTP) on TCP port 3333; the video goes to UDP port 2224 (or 6666) **of the phone** | +| Slot URL | `jieli://192.168.80.1` (or `jieli://` alone) | +| Status | works since 2.16, checked with a YL3: the control part follows a capture of the vendor app ([#187](https://github.com/OpenIPC/decoder/issues/187)) | What the app does, as a capture of the vendor app shows it ([#196](https://github.com/OpenIPC/decoder/issues/196)): - It connects to TCP port 3333 and talks **CTP**: each message is `CTP:`, the length of a topic name, the topic (e.g. `OPEN_RT_STREAM`), the length of a JSON text and the JSON. - `APP_ACCESS` first; the toy answers with `KEEP_ALIVE_INTERVAL` (a timeout, 60 s), its battery and SD card state and `VIDEO_PARAM` (the size, e.g. 640×480 at 25 fps). Then `DATE_TIME` (the phone's time), a `CTP_KEEP_ALIVE` every 7.5 s (a quarter of a shorter timeout), and `OPEN_RT_STREAM` with the size from `VIDEO_PARAM` (640×480 at 25 if the toy sent none). When the slot stops: `CLOSE_RT_STREAM`. - The video was **not** in the capture (PCAPdroid without root cannot see it). After turbodrone's research on the same Jieli SDK it is **RTP/JPEG** (payload type 26) or H.264 (96) to the phone's **UDP port 6666**. The app listens there and plays payload type 26 through its RTP/JPEG path and 96 through its H.264 path; a whole JPEG in a datagram without RTP is shown too. -- A YL3 sent nothing to 6666 but its own stream to the phone's **UDP port 2224**: per datagram a 20-byte header, then a piece of a JPEG. Header, little-endian: byte 0 the type (2 for JPEG, bit 7 set on the last piece of a frame), bytes 2–3 the length of this piece, 4–7 a packet counter, 8–11 the length of the JPEG, 12–15 the offset of the piece in it. The datagram can be longer than the piece; what follows is left over from earlier frames and is ignored (an earlier test build took it, which broke the lower half of the picture). Its frames are portrait (240×320 or 320×240 rotated): the camera is mounted sideways, so `jieli://` turns them 90° counter-clockwise; 270° showed them upside down; `?rotate=` overrides it. The app listens on 2224 as well as 6666; the first five datagrams go to the log with the whole header, `/stats.json` has a `stream` object (`packets`, `frames`, `dropped`, `last_drop`) and `video_port`; why a frame was dropped is in the log for the first three. The debug page serves the last frame as it came, `/jieli-frame.jpg`, and the last dropped one, `/jieli-dropped.bin`, also after the session ended (the app pauses while the phone's browser is open). +- A YL3 sent nothing to 6666 but its own stream to the phone's **UDP port 2224**: pieces of JPEG frames, each behind a 20-byte header, and one datagram can hold several pieces one after another (e.g. 20 + 2964 + 20 + 100 bytes for a 3064-byte frame). Header, little-endian: byte 0 the type (2 for JPEG, bit 7 set on the last piece of a frame), bytes 2–3 the length of this piece, 4–7 a counter, 8–11 the length of the JPEG, 12–15 the offset of the piece in it. Earlier builds read a datagram as one piece (the second header ended up in the picture: broken lower half, wrong colours) or only its first piece (frames longer than one piece were lost and the picture froze); 2.16 reads every piece. Small frames of about 2 KB look blocky and reddish as the toy sends them. Its frames are portrait (240×320 or 320×240 rotated): the camera is mounted sideways, so `jieli://` turns them 90° counter-clockwise; 270° showed them upside down; `?rotate=` overrides it. The app listens on 2224 as well as 6666; the first five datagrams go to the log with the whole header, `/stats.json` has a `stream` object (`packets`, `frames`, `dropped`, `last_drop`) and `video_port`; why a frame was dropped is in the log for the first three. The debug page serves the last frame as it came, `/jieli-frame.jpg`, and the last dropped one, `/jieli-dropped.bin`, also after the session ended (the app pauses while the phone's browser is open). - If nothing arrives on 6666 or 2224 for 5 s after `OPEN_RT_STREAM`, the status says so and the app also listens on UDP 1234 (audio in the vendor SDK), **only to log** what arrives there, for 30 s or 20 packets, then frees the port; it asks for the stream again every 10 s without video. - **No flight command is ever sent**: no `FLIGHT_CONTROL` over CTP, nothing to the toy's UDP port 3456 (where the vendor app sends the sticks). - Ports 6666 and 2224 can be used by one slot at a time: one `jieli://` slot in a grid. @@ -167,5 +167,5 @@ The drones a tester checked in [#187](https://github.com/OpenIPC/decoder/issues/ |-----------|-------------|-----------------------------|----------------------------------|-------------------------------------------------------------------------------------------------------------| | 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 plays, picture quality being checked | +| 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 | diff --git a/docs/transports.md b/docs/transports.md index 5c61f329..768c7516 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -102,7 +102,7 @@ For example Majestic's `http://user:password@/mjpeg`: a `multipart/x-mix `flowufo://192.168.169.1:8800` (or `flowufo://` alone) plays the video of toy drones with a `FLOW-UFO-xxxxxx` Wi‑Fi access point. Since 2.15 ([#187](https://github.com/OpenIPC/decoder/issues/187), PR [#215](https://github.com/OpenIPC/decoder/pull/215)). Port 8800 (the default) probes 8801 as well and keeps the port that answers. Options: `?qpara=<8 hex digits>` and `?dqt=zigzag|raster|q50`. Setup, options and status: [toys.md](toys.md#flow-ufo). -`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; experimental since 2.15. Options: `?w=`, `?h=`, `?fps=`, `?rtp=`, `?format=0|1`, `?rotate=`. See [toys.md](toys.md#hf--jieli-toys). +`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). 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 MJPEG slot) overrides it and is not sent to the camera either. See [toys.md](toys.md#ky-ufo-rtsp-toys).