Skip to content

Commit 100ac9b

Browse files
committed
fix: YL3 frames put together from each piece's own length (bytes 2-3), not the whole datagram; jieli:// turns 90° by default; raw frames outlive the session (#187)
1 parent 3f4ae3e commit 100ac9b

6 files changed

Lines changed: 87 additions & 22 deletions

File tree

‎android/app/src/main/java/com/openipc/decoder/DebugServer.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ void serve(OutputStream out, String path, String query) throws IOException {
217217
case "/jieli-dropped.bin":
218218
// The toy's frames as they came (#187), to see what the picture is made from
219219
Source src = source;
220-
byte[] raw = src != null ? src.jieliRaw(path.startsWith("/jieli-frame")
221-
? "frame" : "dropped") : null;
220+
String which = path.startsWith("/jieli-frame") ? "frame" : "dropped";
221+
byte[] raw = src != null ? src.jieliRaw(which) : Jieli.Client.lastRaw(which);
222222
if (raw != null) {
223223
reply(out, 200, path.endsWith(".jpg") ? "image/jpeg"
224224
: "application/octet-stream", raw);

‎android/app/src/main/java/com/openipc/decoder/Decoder.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1390,7 +1390,7 @@ private void removeQuadOverlay(int index) {
13901390
@Override public byte[] snapshotJpeg(boolean labels) { return debugSnapshotJpeg(labels); }
13911391
@Override public byte[] jieliRaw(String which) {
13921392
RtspClient c = rtspClient;
1393-
return c != null ? c.jieliRaw(which) : null;
1393+
return c != null ? c.jieliRaw(which) : Jieli.Client.lastRaw(which);
13941394
}
13951395
};
13961396
/** A cell counts as playing in a snapshot when it showed a frame this recently (#98). */

‎android/app/src/main/java/com/openipc/decoder/Jieli.java‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,18 @@ final class Jieli {
5656
static final int RTP_PORT = 6666;
5757
/** The toy's own stream (#187): a YL3 sends its JPEG frames here, not as RTP to 6666. */
5858
static final int STREAM_PORT = 2224;
59+
/** The last frame of any session, and the last dropped one, for the debug page. */
60+
static volatile byte[] lastFrameAnySession;
61+
static volatile byte[] lastDroppedAnySession;
5962
/** The port listened on for that stream; STREAM_PORT but for the tests. */
6063
static volatile int streamPort = STREAM_PORT;
6164
static final int DEFAULT_WIDTH = 640;
6265
static final int DEFAULT_HEIGHT = 480;
6366
static final int DEFAULT_FPS = 25;
6467
static final int FORMAT_JPEG = 0;
6568
static final int FORMAT_H264 = 1;
66-
/** The YL3's frames come turned (portrait 240×320), as the K200's do (#187, #200). */
67-
static final int DEFAULT_ROTATE = 270;
69+
/** The YL3's frames come turned (portrait 240×320); 270 showed them upside down (#187). */
70+
static final int DEFAULT_ROTATE = 90;
6871
static final int PT_JPEG = 26;
6972
static final int PT_H264 = 96;
7073

@@ -121,7 +124,7 @@ private Jieli() {}
121124
* refused): w, h, fps (the stream asked for; default what the toy announces in
122125
* VIDEO_PARAM, else 640x480 at 25), rtp (the local UDP port for the video, 6666),
123126
* format (0 JPEG, the default, or 1 H.264), rotate (degrees counter-clockwise for JPEG,
124-
* 0, 90, 180 or 270; default 270: the YL3's camera is mounted sideways like the K200's).
127+
* 0, 90, 180 or 270; default 90: the YL3's camera is mounted sideways).
125128
*/
126129
static final class Url {
127130
String host = DEFAULT_HOST;
@@ -582,14 +585,17 @@ static String subnetHint(InetAddress target, List<InetAddress> local) {
582585

583586
/**
584587
* The toy's own video stream on UDP 2224 (#187), as a YL3 sent it: a 20-byte header,
585-
* then (a piece of) a JPEG. Header as far as seen, little-endian: byte 0 the type (2 for
586-
* JPEG), bytes 4–7 a packet counter, 8–11 the length of the JPEG, 12–15 the offset of
587-
* this piece in it. A 640×480 frame fitted one datagram (4600 bytes, 20 more in the
588-
* datagram); longer ones are put together by offset. Pure Java, for the unit tests.
588+
* then a piece of a JPEG. Header, little-endian: byte 0 the type (2 for JPEG, bit 7 set
589+
* on the last piece of a frame), bytes 2–3 the length of this piece, 4–7 a packet
590+
* counter, 8–11 the length of the JPEG, 12–15 the offset of this piece in it. The
591+
* datagram can be longer than the piece: what follows is left over from earlier frames
592+
* (taking it gave a broken lower half), so only the piece is used. Pure Java, for the
593+
* unit tests.
589594
*/
590595
static final class RtStream {
591596
static final int HEADER = 20;
592597
static final int TYPE_JPEG = 2;
598+
static final int LAST_PIECE = 0x80;
593599
static final int MAX_FRAME = 1 << 20;
594600

595601
private byte[] frame = new byte[64 * 1024];
@@ -605,7 +611,10 @@ static final class RtStream {
605611
private void drop(String reason, int n) {
606612
dropped++;
607613
lastDropReason = reason;
608-
if (n > 0) lastDropped = Arrays.copyOf(frame, n);
614+
if (n > 0) {
615+
lastDropped = Arrays.copyOf(frame, n);
616+
lastDroppedAnySession = lastDropped;
617+
}
609618
}
610619

611620
/**
@@ -614,12 +623,15 @@ private void drop(String reason, int n) {
614623
*/
615624
int feed(byte[] b, int off, int len) {
616625
if (len <= HEADER) return 0;
617-
if ((b[off] & 0xFF) != TYPE_JPEG) {
626+
if ((b[off] & 0x7F) != TYPE_JPEG) {
618627
otherTypes++;
619628
return 0;
620629
}
630+
boolean last = (b[off] & LAST_PIECE) != 0;
621631
int total = le32(b, off + 8);
622632
int at = le32(b, off + 12);
633+
int length = (b[off + 2] & 0xFF) | (b[off + 3] & 0xFF) << 8;
634+
if (length == 0 || length > len - HEADER) length = len - HEADER;
623635
if (total < 4 || total > MAX_FRAME || at < 0 || at >= total) {
624636
drop("bad header: length " + total + ", offset " + at, 0);
625637
return 0;
@@ -633,10 +645,17 @@ int feed(byte[] b, int off, int len) {
633645
} else if (size != total) {
634646
return 0; // a piece of a frame whose start was lost
635647
}
636-
int piece = Math.min(len - HEADER, total - at);
648+
int piece = Math.min(length, total - at);
637649
System.arraycopy(b, off + HEADER, frame, at, piece);
638650
filled += piece;
639-
if (filled < size) return 0;
651+
if (filled < size) {
652+
if (last) {
653+
drop("incomplete: " + filled + " of " + size + " bytes at the last piece",
654+
filled);
655+
size = -1;
656+
}
657+
return 0;
658+
}
640659
int n = size;
641660
size = -1;
642661
int end = jpegEnd(frame, n);
@@ -1247,6 +1266,7 @@ private void stream(DatagramPacket p, Listener listener) {
12471266
int jpeg = rtStream.feed(b, 0, len);
12481267
if (jpeg > 0) {
12491268
lastStreamFrame = Arrays.copyOf(rtStream.frame(), jpeg);
1269+
lastFrameAnySession = lastStreamFrame;
12501270
lastSource = from;
12511271
if (listener.onJpeg(rtStream.frame(), 0, jpeg)) {
12521272
videoPort = streamPort;
@@ -1300,6 +1320,15 @@ byte[] raw(String which) {
13001320
: "dropped".equals(which) ? rtStream.lastDropped : null;
13011321
}
13021322

1323+
/**
1324+
* As {@link #raw} after the session ended too: opening the browser on the same phone
1325+
* pauses the app and ends the session (#187), the frames must outlive it.
1326+
*/
1327+
static byte[] lastRaw(String which) {
1328+
return "frame".equals(which) ? lastFrameAnySession
1329+
: "dropped".equals(which) ? lastDroppedAnySession : null;
1330+
}
1331+
13031332
/** For /stats.json. */
13041333
Map<String, Object> stats() {
13051334
Map<String, Object> m = new LinkedHashMap<>();

‎android/app/src/main/java/com/openipc/decoder/RtspSession.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,8 @@ Map<String, Object> jieliStats() {
783783
/** Raw bytes of a jieli:// slot for the debug page (#187), see Jieli.Client.raw(). */
784784
byte[] jieliRaw(String which) {
785785
Jieli.Client c = jieli;
786-
return c != null ? c.raw(which) : null;
786+
byte[] b = c != null ? c.raw(which) : null;
787+
return b != null ? b : Jieli.Client.lastRaw(which);
787788
}
788789

789790
/**

‎android/app/src/test/java/com/openipc/decoder/JieliTest.java‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ private static int freeUdpPortOrZero() {
7373
/** A datagram of the toy's stream on UDP 2224 as a YL3 sent it (#187). */
7474
private static byte[] streamPacket(int counter, int total, int offset, byte[] piece, int tail) {
7575
byte[] b = new byte[Jieli.RtStream.HEADER + piece.length + tail];
76-
b[0] = 2;
77-
b[2] = (byte) 0x94;
78-
b[3] = 0x0B;
76+
// Type 2, bit 7 on the last piece; bytes 2-3 the length of this piece
77+
b[0] = (byte) (offset + piece.length >= total ? 0x82 : 0x02);
78+
b[2] = (byte) piece.length;
79+
b[3] = (byte) (piece.length >>> 8);
7980
le32(b, 4, counter);
8081
le32(b, 8, total);
8182
le32(b, 12, offset);
@@ -127,6 +128,40 @@ public void rtStreamFrameInPieces() {
127128
assertArrayEquals(j, Arrays.copyOf(st.frame(), n));
128129
}
129130

131+
@Test
132+
public void rtStreamUsesOnlyThePiece() {
133+
// As test build 3 showed it: 2964 of 4600 bytes in a 4640-byte datagram whose rest is
134+
// left over from an earlier frame (even with an FFD9); the rest comes at offset 2964
135+
Jieli.RtStream st = new Jieli.RtStream();
136+
byte[] j = jpeg(4600);
137+
byte[] first = streamPacket(1, 4600, 0, Arrays.copyOfRange(j, 0, 2964), 0);
138+
byte[] dgram = Arrays.copyOf(first, 4640);
139+
byte[] stale = jpeg(4600 - 2964 + 20);
140+
System.arraycopy(stale, 0, dgram, first.length, stale.length);
141+
assertEquals(0, st.feed(dgram, 0, dgram.length));
142+
byte[] second = streamPacket(2, 4600, 2964, Arrays.copyOfRange(j, 2964, 4600), 40);
143+
int n = st.feed(second, 0, second.length);
144+
assertEquals(4600, n);
145+
assertArrayEquals(j, Arrays.copyOf(st.frame(), n));
146+
assertEquals(0, st.dropped);
147+
}
148+
149+
@Test
150+
public void rtStreamDropsAFrameWithAPieceMissing() {
151+
Jieli.RtStream st = new Jieli.RtStream();
152+
byte[] j = jpeg(6000);
153+
byte[] first = streamPacket(1, 6000, 0, Arrays.copyOfRange(j, 0, 2000), 0);
154+
assertEquals(0, st.feed(first, 0, first.length));
155+
// The middle piece is lost; the last one ends the frame
156+
byte[] last = streamPacket(3, 6000, 4000, Arrays.copyOfRange(j, 4000, 6000), 0);
157+
assertEquals(0, st.feed(last, 0, last.length));
158+
assertEquals(1, st.dropped);
159+
assertTrue(st.lastDropReason, st.lastDropReason.startsWith("incomplete"));
160+
// The next frame plays
161+
byte[] next = streamPacket(4, 3000, 0, jpeg(3000), 0);
162+
assertEquals(3000, st.feed(next, 0, next.length));
163+
}
164+
130165
@Test
131166
public void rtStreamDropsBrokenFrames() {
132167
Jieli.RtStream st = new Jieli.RtStream();
@@ -328,7 +363,7 @@ public void urls() throws Exception {
328363
assertEquals("192.168.80.1", u.host);
329364
assertEquals(3333, u.port);
330365
assertEquals(6666, u.rtpPort);
331-
assertEquals(270, u.rotate);
366+
assertEquals(90, u.rotate);
332367
assertEquals(0, Jieli.Url.parse("jieli://?rotate=0").rotate);
333368
assertEquals(90, Jieli.Url.parse("jieli://?rotate=90").rotate);
334369
assertEquals(0, u.width);

‎docs/toys.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ The Wi‑Fi name and the address the phone gets usually tell the family. The toy
4444
|----------------------------------------------------------------|-----------------|----------------------------------------------------------|---------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
4545
| `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) | in testing ([#187](https://github.com/OpenIPC/decoder/issues/187)) |
4646
| `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) | in testing ([#187](https://github.com/OpenIPC/decoder/issues/187)) |
47-
| `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) | test build 4: YL3 video on UDP 2224 plays, lower half broken in test build 3 ([#187](https://github.com/OpenIPC/decoder/issues/187)) |
47+
| `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) | test build 5: YL3 video on UDP 2224, pieces put together by their own length ([#187](https://github.com/OpenIPC/decoder/issues/187)) |
4848

4949
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*).
5050

@@ -109,7 +109,7 @@ What the app does, as a capture of the vendor app shows it ([#196](https://githu
109109
- 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.
110110
- `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`.
111111
- 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.
112-
- A YL3 sent nothing to 6666 but its own stream to the phone's **UDP port 2224**: per datagram a 20-byte header, then the JPEG (seen: byte 0 = 2, a packet counter in bytes 4–7, the JPEG's length in 8–11, an offset in 12–15, little-endian; frames of 4–8 KB in one datagram, 40–60 zero bytes after the JPEG). Its frames are portrait (240×320 when 640×480 was asked for): the camera is mounted sideways, so `jieli://` turns them 270° counter-clockwise like the K200's, `?rotate=` overrides it. The app listens on 2224 as well and shows those JPEGs; longer frames are put together by the offset. 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`.
112+
- 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 (test build 3 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).
113113
- 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.
114114
- **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).
115115
- Ports 6666 and 2224 can be used by one slot at a time: one `jieli://` slot in a grid.
@@ -121,7 +121,7 @@ URL options (as with `udp://`, separated by `?` and `&`):
121121
| `w`, `h`, `fps` | numbers | from `VIDEO_PARAM`, else 640, 480, 25 | the stream asked for in `OPEN_RT_STREAM` |
122122
| `rtp` | a UDP port | `6666` | the local port the video is expected on |
123123
| `format` | `0`, `1` | `0` | `0` asks for JPEG (as the vendor app does), `1` for H.264 |
124-
| `rotate` | `0`, `90`, `180`, `270` | `270` | degrees counter-clockwise the JPEG frames are turned (the YL3's camera is mounted sideways) |
124+
| `rotate` | `0`, `90`, `180`, `270` | `90` | degrees counter-clockwise the JPEG frames are turned (the YL3's camera is mounted sideways) |
125125

126126
Examples: `jieli://192.168.80.1`, `jieli://192.168.80.1?w=1280&h=720`, `jieli://192.168.80.1?format=1`.
127127

0 commit comments

Comments
 (0)