diff --git a/README.md b/README.md index 21cf5b8f..e8d710a2 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ While the app runs it serves its debug information over HTTP on port **8085**, f - `http://:8085/` — the snapshot fills the page, with each cell's camera, size, fps and losses (in red) drawn over it; the header shows the version, the device time and uptime. **Info On / Off** in the header hides the text over the cells (and the list below them on a narrow screen), leaving only the picture; the choice is remembered in the browser. A red note in the header says when the device's video decoder stopped responding (`decoder_stuck`); - `http://:8085/snapshot.jpg` (`?labels=0`: frames only) — a picture laid out like the screen, at the screen's resolution (at most 1920×1080), made from the frames the app shows right now; a cell without video is black with its status. Taken only when asked for; the page refreshes it every 3, 5 or 10 s while the tab is visible; - `http://:8085/stats.json` — the same as JSON, for scripts; +- In single view the page and the snapshot still show the whole grid (#100): the camera on screen is framed in blue, every other configured slot gets a short stream check (connect, a few seconds of packets, no decoding) about every 45 s while the page is open, and its cell says what was found, e.g. `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`, or the error. A slot of the camera on screen is never checked (some cameras take one client only), nor a `udp://` slot. `stats.json` has these cells under `slots`; - `http://:8085/log` — the last 500 lines of the app's log. Only clients on the local network are answered (loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7); others get 403. Logins and passwords are never shown. diff --git a/README.ru.md b/README.ru.md index 9b52f265..13f1ae38 100644 --- a/README.ru.md +++ b/README.ru.md @@ -147,6 +147,7 @@ outgoing: - `http://<приставка>:8085/` — снимок на всю страницу, поверх каждой ячейки камера, размер, fps и потери (красным); в шапке версия, время приставки и аптайм. Переключатель **Info On / Off** в шапке убирает текст поверх ячеек (и список под ними на узком экране), остаётся только картинка; выбор запоминается в браузере. Красная пометка в шапке сообщает, что видеодекодер устройства перестал отвечать (`decoder_stuck`); - `http://<приставка>:8085/snapshot.jpg` (`?labels=0` — только кадры) — картинка с раскладкой экрана в разрешении экрана (не больше 1920×1080) из кадров, которые приложение показывает сейчас; ячейка без видео — чёрная с её статусом. Снимается только по запросу; страница обновляет её раз в 3, 5 или 10 с, пока вкладка видна; - `http://<приставка>:8085/stats.json` — то же в JSON, для скриптов; +- В одиночном виде страница и снимок всё равно показывают всю сетку (#100): камера на экране обведена синей рамкой, а каждый другой настроенный слот, пока страница открыта, примерно раз в 45 с получает короткую проверку потока (подключение, несколько секунд пакетов, без декодирования); в его ячейке — результат, например `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`, или ошибка. Слот с камерой, которая на экране, не проверяется (некоторые камеры принимают только одного клиента), как и слоты `udp://`. В `stats.json` эти ячейки — в поле `slots`; - `http://<приставка>:8085/log` — последние 500 строк лога приложения. Отвечает только клиентам из локальной сети (loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7), остальным — 403. Логины и пароли не показываются. diff --git a/README.zh-CN.md b/README.zh-CN.md index fe88c83a..6a240c5c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -147,6 +147,7 @@ outgoing: - `http://<设备>:8085/` — 快照铺满页面,每个画面上叠加摄像机、分辨率、帧率和丢帧(红色);页头显示版本、设备时间和运行时长。页头的 **Info On / Off** 开关可隐藏画面上的文字(以及窄屏时下方的列表),只保留画面;选择会保存在浏览器中。设备视频解码器停止响应时,页头会出现红色提示(`decoder_stuck`); - `http://<设备>:8085/snapshot.jpg`(`?labels=0`:仅画面)— 按屏幕布局拼成的图片,分辨率与屏幕相同(最大 1920×1080),取自应用当前显示的画面;没有视频的画面为黑色并显示其状态。仅在请求时截取;页面在标签页可见时每 3、5 或 10 秒刷新一次; - `http://<设备>:8085/stats.json` — 相同内容的 JSON,便于脚本使用; +- 单画面模式下,页面和快照仍显示完整网格(#100):屏幕上的摄像机用蓝框标出;页面打开期间,其他每个已配置的槽位约每 45 秒进行一次简短的流检测(连接、接收几秒数据包、不解码),其画面显示检测结果,例如 `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`,或错误信息。与屏幕上相同的摄像机的槽位不检测(部分摄像机只接受一个客户端),`udp://` 槽位也不检测。`stats.json` 中这些画面位于 `slots` 字段; - `http://<设备>:8085/log` — 应用日志的最后 500 行。 仅响应本地网络中的客户端(loopback、10/8、172.16/12、192.168/16、链路本地地址、IPv6 fc00::/7),其他地址返回 403。不会显示用户名和密码。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 0f8e6ea3..751e19f5 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 27 - versionName "2.02" + versionCode 28 + versionName "2.03" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } diff --git a/android/app/src/main/java/com/openipc/decoder/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java index 924261d2..083c00ae 100644 --- a/android/app/src/main/java/com/openipc/decoder/DebugServer.java +++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java @@ -399,7 +399,8 @@ private static void closeQuietly(Socket s) { * screen the snapshot takes the full width and the cells are listed below it (#103). It * fetches /stats.json every 1.5 s and the snapshot (without labels) every 3, 5 or 10 s, * both only while the tab is visible (#98). The Info switch hides the text over the cells - * and the list, leaving the header and the snapshot (#141). + * and the list, leaving the header and the snapshot (#141). In single view the grid stays, + * with the camera on screen framed and the stream checks of the other slots (#100). */ private static final String PAGE = "\n" + "'+esc(c.text)+'';\n" + + "function ok(c){return c.probe&&c.probe.state==='ok'}\n" + + "function info(c){if(!c.video)return ''+" + + "esc(c.text)+'';\n" + "var t=esc(c.text),r=/(\\d+)\\/(\\d+) fps/.exec(c.text||'');\n" + "if(r&&+r[1]<0.9*r[2])t=t.replace(r[0],''+r[0]+'');\n" + "if(c.lost_fps>0)t+=' \u00b7 lost '+num(c.lost_fps)" @@ -499,11 +504,14 @@ private static void closeQuietly(Socket s) { + "return t}\n" // One cell: slot, camera and transport on top; size, fps and losses below + "function cell(c){var top=''+esc(c.slot)+''+where(c)+'';\n" - + "if(!c.video)return '
'+esc(c.slot)+'" - + "'+esc(c.text)+'
'+top+'
';\n" - + "return '
'+top+'
" - + "'+info(c)+'
'}\n" + + " class=\"dim\">'+where(c)+(c.on_screen?' \u00b7 on screen':'')+''," + + "cls=c.on_screen?'cell on':'cell';\n" + + "if(!c.video)return '
" + + "'+esc(c.slot)+''+esc(c.text)+'
'+top+" + + "'
';\n" + + "return '
'+top+'
'+info(c)+'
'}\n" + "function row(c){return ''+esc(c.slot)+'" + "'+where(c)+'
'+info(c)+''}\n" + "function render(s){" @@ -518,8 +526,11 @@ private static void closeQuietly(Socket s) { + ":ui?'UI '+s.ui_thread:none?(s.mode||'no data'):'';\n" + "$('dot').className='dot '+(stuck?'bad':ui||none?'warn':'ok');\n" + "var list,side=1;\n" - + "if(s.mode==='paused'||!(s.cells||s.single))" + + "if(s.mode==='paused'||!(s.cells||s.single||s.slots))" + "list=[{text:s.mode==='paused'?'App in background':(s.mode||'')}];\n" + // Single and U view (#100): the grid with the camera on screen and the checks of + // the others + + "else if(s.slots){list=s.slots.slice();side=list.length>4?3:2}\n" // The U view is the U cell alone; the 3x3 grid has it as its ninth cell (#87) + "else if(s.cells){side=s.mode==='udp'?1:s.cells.length>4?3:2;list=s.cells.slice();" + "while(list.length snapshotCells(int[] side) { side[0] = 1; return cells; } - if (udpViewShown) { - cells.add(snapshotCell(U_CELL)); - side[0] = 1; - return cells; - } - if (!quadEnabled) { - Snapshots.Cell c = new Snapshots.Cell(); - c.slot = mActive + 1; - c.live = mResumed && "connected".equals(singleState) - && singleRenderedWithin(SNAPSHOT_LIVE_MS); - c.label = statusText != null && statusText.getVisibility() == View.VISIBLE - ? statusText.getText().toString() : c.live ? "Playing" : "No new frames"; - if (c.live) { - c.frame = mSurface.getBitmap(width, Snapshots.height(width)); + if (udpViewShown || !quadEnabled) { + // The grid as in multi-view (#100): the camera on screen, the others' checks + updateProbes(); + int size = probeGridSize(); + long now = SystemClock.elapsedRealtime(); + for (int i = 0; i < size * size; i++) { + Snapshots.Cell c; + if (!onScreen(i)) { + c = new Snapshots.Cell(); + c.slot = i == U_CELL ? 0 : i + 1; + StreamProbe.Result r = probeResult(i); + c.label = probeText(i, now); + c.ok = r != null && r.ok; + } else if (udpViewShown) { + c = snapshotCell(U_CELL); + } else { + c = new Snapshots.Cell(); + c.slot = i + 1; + c.live = "connected".equals(singleState) + && singleRenderedWithin(SNAPSHOT_LIVE_MS); + c.label = statusText != null && statusText.getVisibility() == View.VISIBLE + ? statusText.getText().toString() : c.live ? "Playing" : "No new frames"; + if (c.live) { + c.frame = mSurface.getBitmap(width / size, Snapshots.height(width) / size); + } + } + c.onScreen = onScreen(i); + cells.add(c); } - cells.add(c); - side[0] = 1; + side[0] = size; return cells; } int size = quadGridSize(mHosts, DEFAULT_URL); @@ -1351,7 +1373,7 @@ private Snapshots.Cell snapshotCell(int i) { QuadCell cell = quad != null && i < quad.length ? quad[i] : null; TextView stats = quadStats[i]; if (cell == null) { - c.label = "not configured"; + c.label = StreamProbes.NOT_CONFIGURED; } else { c.label = stats != null ? stats.getText().toString() : ""; c.live = cell.showsVideo(SNAPSHOT_LIVE_MS); @@ -1470,7 +1492,7 @@ private Map debugState() { QuadCell cell = cells[i]; if (cell == null) { c.put("video", false); - c.put("text", "not configured"); + c.put("text", StreamProbes.NOT_CONFIGURED); } else { c.put("video", cell.showsVideo(SNAPSHOT_LIVE_MS)); TextView v = quadStats[i]; @@ -1500,9 +1522,134 @@ private Map debugState() { } m.put("cells", list); } + if (udpViewShown || !quadEnabled) { + updateProbes(); + m.put("slots", probeSlots()); + } return m; } + // ====================== Stream checks in single view (#100) ====================== + + /** Single and U view: the debug page is in use, the other slots get short checks. */ + private void updateProbes() { + String[] urls = new String[CAM_COUNT]; + for (int i = 0; i < CAM_COUNT; i++) { + urls[i] = mHosts[i] != null && !mHosts[i].isEmpty() && !mHosts[i].equals(DEFAULT_URL) + ? mHosts[i] : null; + } + streamProbes.update(urls, mTransports, udpViewShown ? null : mHost, + mResumed && (udpViewShown || !quadEnabled)); + } + + /** + * The grid of the debug page in single and U view: 3x3 in the U view (it has the U cell), + * and when the slot on screen is beyond the 2x2 grid, even an empty one (Qodo on #163). + */ + private int probeGridSize() { + if (udpViewShown || mActive >= 4) return 3; + return quadGridSize(mHosts, DEFAULT_URL); + } + + /** Cell i shows what the TV shows: the active slot, or the U cell in the U view. */ + private boolean onScreen(int i) { + return udpViewShown ? i == U_CELL : i == mActive; + } + + private StreamProbe.Result probeResult(int i) { + return i < CAM_COUNT && mHosts[i] != null + ? streamProbes.result(i, mHosts[i], mTransports[i]) : null; + } + + /** What a cell not on screen says: its last check, or why it has none. */ + private String probeText(int i, long nowMs) { + if (i == U_CELL) return "U \u00b7 not checked in single view"; + String skip = StreamProbes.skipReason(mHosts[i], DEFAULT_URL, + udpViewShown ? null : mHost); + if (skip != null) return skip; + StreamProbe.Result r = probeResult(i); + if (r != null) return r.text(nowMs) + (streamProbes.isRunning(i) ? " \u00b7 checking" : ""); + return streamProbes.isRunning(i) ? "Checking stream\u2026" : "Waiting for a stream check"; + } + + /** + * Transport of a grid cell in single and U view (Qodo on #163): in use for the camera on + * screen, otherwise what a check of it goes over. + */ + private String slotTransport(int i, String url) { + if (i == U_CELL) return "UDP push"; + if (onScreen(i)) { + return Transport.label(rtspClient != null ? rtspClient.transportInUse() : null, + mTransports[i]); + } + if (RtpListener.Url.matches(url)) return "UDP listen"; + if (HttpMjpeg.matches(url)) return "HTTP"; + Transport.Mode m = StreamProbe.probeMode(mTransports[i]); + return "check over " + (m == Transport.Mode.MULTICAST ? "multicast" : m.name()); + } + + /** + * Every cell of the grid for /stats.json in single and U view (#100): the camera on screen + * as it plays, the others with the result of their last check. UI thread only. + */ + private List probeSlots() { + int size = probeGridSize(); + long now = SystemClock.elapsedRealtime(); + List list = new ArrayList<>(); + for (int i = 0; i < size * size; i++) { + boolean u = i == U_CELL; + Map c = new LinkedHashMap<>(); + c.put("slot", u ? "U" : i + 1); + c.put("on_screen", onScreen(i)); + String url = u ? UDP_CELL_URL : mHosts[i]; + if (u || (url != null && !url.isEmpty() && !url.equals(DEFAULT_URL))) { + c.put("url", u ? UDP_CELL_URL : DebugServer.redact(url)); + c.put("transport", slotTransport(i, url)); + c.put("transport_mode", u ? "UDP" : mTransports[i] != null + ? mTransports[i].name() : null); + } + if (onScreen(i)) { + if (udpViewShown) { + QuadCell[] cells = quadCells; + QuadCell cell = cells != null ? cells[U_CELL] : null; + TextView v = quadStats[U_CELL]; + c.put("video", cell != null && cell.showsVideo(SNAPSHOT_LIVE_MS)); + c.put("text", v != null ? v.getText().toString() : null); + } else { + boolean video = mResumed && "connected".equals(singleState) + && singleRenderedWithin(SNAPSHOT_LIVE_MS); + c.put("video", video); + c.put("text", video ? (mVideoWidth > 0 ? mVideoWidth + "\u00d7" + mVideoHeight + " " : "") + + (rtspClient != null && rtspClient.isJpeg() ? "MJPEG" + : codecH265 ? "H.265" : "H.264") + " \u00b7 " + singleOutFps + " fps" + : statusText != null && statusText.getVisibility() == View.VISIBLE + ? statusText.getText().toString() : singleState); + } + } else { + c.put("video", false); + c.put("text", probeText(i, now)); + StreamProbe.Result r = probeResult(i); + Map p = new LinkedHashMap<>(); + p.put("state", r != null ? (r.ok ? "ok" : "error") + : streamProbes.isRunning(i) ? "checking" : u || StreamProbes.skipReason( + mHosts[i], DEFAULT_URL, udpViewShown ? null : mHost) != null + ? "not checked" : "waiting"); + if (r != null) { + p.put("checked_s_ago", (now - r.atMs) / 1000); + p.put("summary", r.summary); + if (r.codec != null) p.put("codec", r.codec); + if (r.width > 0) p.put("size", r.width + "x" + r.height); + p.put("fps", r.fps); + p.put("kbps", r.kbps); + p.put("key_frames", r.keyFrames); + } + c.put("probe", p); + } + list.add(c); + } + return list; + } + // ====================== Screen / zoom ====================== private static final int REQUEST_STORAGE_FOR_SCREENSHOT = 1; @@ -1806,6 +1953,7 @@ private boolean skipForKeyFrames(Frame buffer) { protected void onPause() { super.onPause(); mResumed = false; + streamProbes.stopAll(); menuManager.onPauseDismissBrowser(); // The UDP view is not a saved mode: after a resume the slot or grid comes back leaveUdpView(); @@ -1901,6 +2049,7 @@ private void stopExecutor() { protected void onDestroy() { super.onDestroy(); DebugServer.get().clearSource(debugSource); + streamProbes.shutdown(); audioManager.shutdown(); } @@ -1958,6 +2107,8 @@ private void startStreams() { mSurface.setVisibility(View.VISIBLE); boolean configured = mHost != null && !mHost.isEmpty() && !mHost.equals(DEFAULT_URL); if (configured) { + // A probe of this camera ends first: some take one client only (#100) + streamProbes.willShow(mHost); rtspClient.configure(mHost, mUserAgent, mTransports[mActive]); startListener(); rtspClient.start(); diff --git a/android/app/src/main/java/com/openipc/decoder/Snapshots.java b/android/app/src/main/java/com/openipc/decoder/Snapshots.java index 003f44ce..58296b35 100644 --- a/android/app/src/main/java/com/openipc/decoder/Snapshots.java +++ b/android/app/src/main/java/com/openipc/decoder/Snapshots.java @@ -58,6 +58,10 @@ static final class Cell { /** Playing right now; otherwise only the label is drawn. */ boolean live; String label; + /** Not on screen, but its last stream check found the stream (#100): label in green. */ + boolean ok; + /** What the TV shows, in a grid drawn for the single view (#100): framed. */ + boolean onScreen; } private static HandlerThread copyThread; @@ -120,6 +124,11 @@ static byte[] render(List cells, int side, int width, boolean labels) { Paint frames = new Paint(Paint.FILTER_BITMAP_FLAG); Paint grid = new Paint(); grid.setColor(Color.DKGRAY); + // The camera on screen in a grid drawn for the single view (#100) + Paint onScreen = new Paint(); + onScreen.setColor(0xFF3D8BFF); + onScreen.setStyle(Paint.Style.STROKE); + onScreen.setStrokeWidth(4); int copied = 0; for (int i = 0; i < n; i++) { Cell c = cells.get(i); @@ -137,6 +146,9 @@ static byte[] render(List cells, int side, int width, boolean labels) { drawLabel(canvas, c, shown, x, y, cw, ch, bandH, text, band); canvas.drawRect(x, y, x + cw, y + 1, grid); canvas.drawRect(x, y, x + 1, y + ch, grid); + if (c.onScreen && side > 1) { + canvas.drawRect(x + 2, y + 2, x + cw - 2, y + ch - 2, onScreen); + } } } long t1 = SystemClock.elapsedRealtime(); @@ -167,8 +179,10 @@ private static void drawLabel(Canvas canvas, Cell c, boolean shown, int x, int y text.setColor(Color.WHITE); canvas.drawText(label, x + 4, y + ch - 6, text); } else { - // No video: slot number and status in the middle, in red - text.setColor(0xFFFF6060); + // No video: slot number and status in the middle, in red; a stream found by a + // check of a slot not on screen in green, an empty slot grey (#100) + text.setColor(c.ok ? 0xFF7FD07F + : StreamProbes.NOT_CONFIGURED.equals(c.label) ? 0xFF888888 : 0xFFFF6060); float w = text.measureText(label); canvas.drawText(label, x + Math.max(4, (cw - w) / 2), y + (ch + text.getTextSize()) / 2f, text); diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbe.java b/android/app/src/main/java/com/openipc/decoder/StreamProbe.java new file mode 100644 index 00000000..a7e3bee9 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbe.java @@ -0,0 +1,361 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * StreamProbe.java — a short look at a camera's stream without decoding it (#100) + * + */ + +package com.openipc.decoder; + +import android.os.SystemClock; + +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * One check of a slot that is not on screen, for the debug page in single view: an ordinary + * RTSP session (or an HTTP MJPEG request) without audio, a few seconds of packets counted, + * then closed. Nothing is decoded and no decoder is taken from the stream on screen. + */ +final class StreamProbe { + /** Connect, handshake and PLAY together; a camera slower than this counts as not answering. */ + static final long CONNECT_WINDOW_MS = 8000; + /** Packets are counted this long after PLAY. */ + static final long RECEIVE_MS = 3000; + + /** What one probe found; immutable once made. */ + static final class Result { + /** URL and transport of the check ({@link #key}): a result for another setting is not shown. */ + final String key; + /** When the probe ended (elapsedRealtime). */ + final long atMs; + final boolean ok; + /** Codec, size, frame rate and bitrate when ok; otherwise why not. */ + final String summary; + final String codec; + final int width; + final int height; + final float fps; + final int keyFrames; + final long kbps; + + Result(String key, long atMs, boolean ok, String summary, Counter c) { + this.key = key; + this.atMs = atMs; + this.ok = ok; + this.summary = summary; + this.codec = c != null ? c.codec() : null; + this.width = c != null ? c.width : 0; + this.height = c != null ? c.height : 0; + this.fps = c != null ? c.fps() : 0; + this.keyFrames = c != null ? c.keyFrames : 0; + this.kbps = c != null ? c.kbps() : 0; + } + + /** One line for a cell: the result and how long ago it was found. */ + String text(long nowMs) { + return (ok ? "Stream OK \u00b7 " : "") + summary + " \u00b7 checked " + + age(nowMs - atMs) + " ago"; + } + } + + /** The transport a probe uses: Auto would wait for UDP first, a probe takes TCP. */ + static Transport.Mode probeMode(Transport.Mode mode) { + return mode == null || mode == Transport.Mode.AUTO ? Transport.Mode.TCP : mode; + } + + /** What a result belongs to: the URL and the transport it was checked over. */ + static String key(String url, Transport.Mode mode) { + return url == null ? null : probeMode(mode).name() + " " + url; + } + + /** "12 s", "3 min": the age of a result. */ + static String age(long ms) { + long s = Math.max(0, ms) / 1000; + return s < 120 ? s + " s" : s / 60 + " min"; + } + + /** + * Counts what arrives on the video channel: packets, frames (marker bits), key frames and + * bytes. The size is known only for MJPEG: H.264/H.265 would need the SPS parsed or a + * decoder, and the probe has none. + */ + static final class Counter { + int videoPt = -1; + boolean h265; + boolean jpeg; + int packets; + int frames; + int keyFrames; + long bytes; + int width; + int height; + long firstMs = -1; + long lastFrameMs = -1; + long firstFrameMs = -1; + private boolean keyInFrame; + + void onSdp(Sdp sdp) { + videoPt = sdp.videoPt; + h265 = sdp.videoH265; + jpeg = sdp.videoJpeg; + } + + /** + * One RTP packet of the video channel, at {@code nowMs}. Returns false for one that + * carries no video: another payload type, or a header without payload (Qodo on #163). + */ + boolean onPacket(byte[] buf, int offset, int length, long nowMs) { + int header = RtspSession.rtpHeaderLength(buf, offset, length); + if (header < 0 || (buf[offset + 1] & 0x7F) != videoPt) return false; + int end = RtspSession.rtpPayloadEnd(buf, offset, length); + if (end <= header) return false; + packets++; + bytes += length; + if (firstMs < 0) firstMs = nowMs; + int start = offset + header; + end += offset; + if (jpeg) { + // RTP/JPEG header (RFC 2435): width and height in 8-pixel units at bytes 6 and 7 + if (end - start >= 8 && width == 0) { + width = (buf[start + 6] & 0xFF) * 8; + height = (buf[start + 7] & 0xFF) * 8; + } + } else if (keyNal(buf, start, end, h265)) { + keyInFrame = true; + } + // The marker bit ends an access unit: one frame + if ((buf[offset + 1] & 0x80) != 0) frameEnd(nowMs); + return true; + } + + /** + * One part of an HTTP MJPEG slot; returns false unless it is a JPEG: it starts with + * SOI and has a frame header or ends with EOI (Qodo on #163). + */ + boolean onJpegFrame(byte[] buf, int offset, int length, long nowMs) { + if (length < 4 || (buf[offset] & 0xFF) != 0xFF || (buf[offset + 1] & 0xFF) != 0xD8) { + return false; + } + byte[] head = offset == 0 ? buf + : java.util.Arrays.copyOfRange(buf, offset, offset + Math.min(length, 4096)); + int[] size = JpegAssembler.size(head, Math.min(length, head.length)); + boolean eoi = endsWithEoi(buf, offset, length); + if (size == null && !eoi) return false; + jpeg = true; + packets++; + bytes += length; + if (firstMs < 0) firstMs = nowMs; + if (width == 0 && size != null) { + width = size[0]; + height = size[1]; + } + frameEnd(nowMs); + return true; + } + + /** FF D9 at the end, allowing for a few bytes of padding after it. */ + private static boolean endsWithEoi(byte[] buf, int offset, int length) { + for (int i = offset + length - 2; i >= Math.max(offset + 2, offset + length - 8); i--) { + if ((buf[i] & 0xFF) == 0xFF && (buf[i + 1] & 0xFF) == 0xD9) return true; + } + return false; + } + + private void frameEnd(long nowMs) { + frames++; + if (keyInFrame) keyFrames++; + keyInFrame = false; + if (firstFrameMs < 0) firstFrameMs = nowMs; + lastFrameMs = nowMs; + } + + /** A key frame NAL unit in this payload: alone, first piece of a fragment, or aggregated. */ + static boolean keyNal(byte[] buf, int start, int end, boolean h265) { + int nalHeader = h265 ? 2 : 1; + if (end - start < nalHeader) return false; + int type = NalAssembler.fragment(buf[start], h265); + if (h265 ? type == 49 : type == 28) { + // FU: the start bit and the type of the unit it carries + if (end - start <= nalHeader) return false; + int fu = buf[start + nalHeader] & 0xFF; + int inner = h265 ? fu & 0x3F : fu & 0x1F; + return (fu & 0x80) != 0 && QuadCell.isKeyFrameNal(inner, h265); + } + if (h265 ? type == 48 : type == 24) { + // Aggregation: 16-bit size, then the unit, for each unit + int pos = start + nalHeader; + while (end - pos >= 2 + nalHeader) { + int size = ((buf[pos] & 0xFF) << 8) | (buf[pos + 1] & 0xFF); + pos += 2; + if (size < nalHeader || size > end - pos) return false; + if (QuadCell.isKeyFrameNal(NalAssembler.fragment(buf[pos], h265), h265)) { + return true; + } + pos += size; + } + return false; + } + return QuadCell.isKeyFrameNal(type, h265); + } + + String codec() { + return videoPt < 0 && !jpeg ? null : jpeg ? "MJPEG" : h265 ? "H.265" : "H.264"; + } + + /** Frames per second between the first and the last frame; 0 below two frames. */ + float fps() { + if (frames < 2 || lastFrameMs <= firstFrameMs) return 0; + return (frames - 1) * 1000f / (lastFrameMs - firstFrameMs); + } + + /** Video bitrate in kbit/s from the first packet to the last frame. */ + long kbps() { + if (firstMs < 0 || lastFrameMs <= firstMs) return 0; + return bytes * 8 / (lastFrameMs - firstMs); + } + + /** "1280×720 MJPEG · 5 fps · 2.1 Mbit/s"; size only when known. */ + String summary() { + StringBuilder sb = new StringBuilder(); + if (width > 0) sb.append(width).append('\u00d7').append(height).append(' '); + sb.append(codec() != null ? codec() : "video"); + float f = fps(); + if (f > 0) { + sb.append(" \u00b7 ").append(f < 10 ? String.format(Locale.ROOT, "%.1f", f) + : String.valueOf(Math.round(f))).append(" fps"); + } + long k = kbps(); + if (k > 0) { + sb.append(" \u00b7 ").append(k < 1000 ? k + " kbit/s" + : String.format(Locale.ROOT, "%.1f Mbit/s", k / 1000f)); + } + return sb.toString(); + } + } + + /** + * Result of a probe from what it saw: no PLAY (the last error, or no answer), PLAY but no + * video packets, or the stream. + */ + static Result result(String key, long atMs, boolean played, String failure, Counter c) { + if (!played) { + return new Result(key, atMs, false, failure != null ? QuadCell.shortError(failure) + : "No answer from camera", null); + } + if (c.frames == 0) { + return new Result(key, atMs, false, c.packets > 0 + ? "Packets but no whole frame" : "PLAY OK, but no video", c); + } + return new Result(key, atMs, true, c.summary(), c); + } + + /** Asked between steps: the probe stops early, e.g. the TV switched to that camera. */ + interface Cancel { + boolean cancelled(); + + /** + * Starts the probe's session unless cancelled already; returns false then. A cancel + * after this stops the session at once rather than at the probe's next look at + * {@link #cancelled()} (Qodo on #163). + */ + default boolean start(RtspSession session) { + if (cancelled()) return false; + session.start(); + return true; + } + } + + private StreamProbe() {} + + /** + * Runs one probe on the calling thread, at most CONNECT_WINDOW_MS + RECEIVE_MS. Returns + * null when cancelled: a cut-short look says nothing. + */ + static Result run(String tag, String url, Transport.Mode mode, String userAgent, Cancel cancel) { + Counter counter = new Counter(); + CountDownLatch played = new CountDownLatch(1); + // Played or failed: a failure ends the check at once, before the session's own + // reconnects try the login again (a wrong password would get several attempts per check) + CountDownLatch settled = new CountDownLatch(1); + String[] failure = new String[1]; + RtspSession session = new RtspSession(tag, new RtspSession.Callback() { + @Override + public void onStatus(String status) { + if ("buffering".equals(status) || "connected".equals(status)) { + played.countDown(); + settled.countDown(); + } + } + + @Override + public void onFailure(String message) { + failure[0] = message; + settled.countDown(); + } + + @Override + public void onSdp(Sdp sdp) { + synchronized (counter) { + counter.onSdp(sdp); + } + } + + @Override + public boolean onPacket(int channel, byte[] buf, int offset, int length) { + if (channel != RtspSession.CHANNEL_VIDEO) return false; + synchronized (counter) { + return counter.onPacket(buf, offset, length, SystemClock.elapsedRealtime()); + } + } + + @Override + public boolean onJpegFrame(byte[] buf, int offset, int length) { + synchronized (counter) { + return counter.onJpegFrame(buf, offset, length, SystemClock.elapsedRealtime()); + } + } + }); + session.configure(url, userAgent, false, probeMode(mode)); + if (!cancel.start(session)) return null; + boolean ok = false; + try { + ok = waitFor(settled, CONNECT_WINDOW_MS, cancel) && played.getCount() == 0; + if (ok && !cancel.cancelled()) sleep(RECEIVE_MS, cancel); + } finally { + session.stop(); + } + if (cancel.cancelled()) return null; + synchronized (counter) { + return result(key(url, mode), SystemClock.elapsedRealtime(), ok, failure[0], counter); + } + } + + private static boolean waitFor(CountDownLatch latch, long ms, Cancel cancel) { + long end = SystemClock.elapsedRealtime() + ms; + try { + while (!cancel.cancelled()) { + long left = end - SystemClock.elapsedRealtime(); + if (left <= 0) return false; + if (latch.await(Math.min(left, 200), TimeUnit.MILLISECONDS)) return true; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return false; + } + + private static void sleep(long ms, Cancel cancel) { + long end = SystemClock.elapsedRealtime() + ms; + try { + while (!cancel.cancelled()) { + long left = end - SystemClock.elapsedRealtime(); + if (left <= 0) return; + Thread.sleep(Math.min(left, 200)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbes.java b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java new file mode 100644 index 00000000..9e3d0946 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java @@ -0,0 +1,308 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * StreamProbes.java — short stream checks of the slots not on screen, for the debug page (#100) + * + */ + +package com.openipc.decoder; + +import android.os.SystemClock; +import android.util.Log; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * In single view only one camera has a session, so the debug page would know nothing about + * the others. While the page is in use (it asks for the state every 1.5 s, which calls + * {@link #update}), each other configured slot gets a {@link StreamProbe} every PERIOD_MS, + * one or two at a time. A slot of the camera on screen is never probed: some cameras take + * one RTSP client only (XM). Nothing runs once the page has not asked for IDLE_MS, or in + * multi-view, where every cell has its own session. + */ +final class StreamProbes { + /** Status of an empty slot: shown grey, not as an error. */ + static final String NOT_CONFIGURED = "not configured"; + + private static final String TAG = "OpenIPCDecoder"; + /** A slot is checked again this long after its last check began. */ + static final long PERIOD_MS = 45_000; + /** No request from the debug page for this long: no new probes. */ + static final long IDLE_MS = 60_000; + /** Probes at the same time: a few sockets, never more load than that. */ + static final int MAX_RUNNING = 2; + private static final long TICK_MS = 2000; + + /** Why a slot gets no probe, or null if it gets one. */ + static String skipReason(String url, String defaultUrl, String screenUrl) { + if (url == null || url.trim().isEmpty() || url.equals(defaultUrl)) return NOT_CONFIGURED; + // 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"; + String e = endpoint(url); + if (e == null) return "invalid URL"; + if (e.equals(endpoint(screenUrl))) return "same camera as on screen"; + return null; + } + + /** "host:port" of an RTSP or HTTP URL, lower case, or null. */ + static String endpoint(String url) { + if (url == null) return null; + try { + if (HttpMjpeg.matches(url)) { + HttpMjpeg.Url u = HttpMjpeg.Url.parse(url); + return u.host.toLowerCase(Locale.ROOT) + ":" + u.port; + } + if (RtpListener.Url.matches(url)) return null; + RtspSession.Url u = RtspSession.Url.parse(url); + return u.host == null || u.host.isEmpty() ? null + : u.host.toLowerCase(Locale.ROOT) + ":" + u.port; + } catch (IOException | RuntimeException e) { + return null; + } + } + + /** + * The slot to probe next, or -1: one that may be probed, is not running, has no running + * probe to the same camera, and whose last start is at least PERIOD_MS ago; the one + * waiting longest first (never checked before all others), the lower slot on a tie. + */ + static int next(String[] urls, String[] skip, long[] lastStartMs, boolean[] running, + long nowMs) { + Set busy = new HashSet<>(); + for (int i = 0; i < urls.length; i++) { + if (running[i]) busy.add(endpoint(urls[i])); + } + int best = -1; + for (int i = 0; i < urls.length; i++) { + if (skip[i] != null || running[i] || busy.contains(endpoint(urls[i]))) continue; + if (lastStartMs[i] > 0 && nowMs - lastStartMs[i] < PERIOD_MS) continue; + if (best < 0 || lastStartMs[i] < lastStartMs[best]) best = i; + } + return best; + } + + private final String userAgent; + private final Object lock = new Object(); + // Guarded by lock: the slots as of the last update + private String[] urls = new String[0]; + private Transport.Mode[] modes = new Transport.Mode[0]; + /** {@link StreamProbe#key} per slot: URL and probe transport. */ + private String[] keys = new String[0]; + private String screenUrl; + private boolean wanted; + private long lastUpdateMs; + private long[] lastStartMs = new long[0]; + private final boolean[] running = new boolean[Decoder.CAM_COUNT]; + private final Probe[] probes = new Probe[Decoder.CAM_COUNT]; + private ScheduledFuture ticker; + /** After shutdown(): nothing is scheduled any more. */ + private boolean shut; + + private final ConcurrentHashMap results = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1, r -> { + Thread t = new Thread(r, "probe-sched"); + t.setDaemon(true); + return t; + }); + private final ExecutorService workers = new ThreadPoolExecutor(MAX_RUNNING, MAX_RUNNING, + 30, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), r -> { + // Background priority: the stream on screen comes first + Thread t = new Thread(() -> { + android.os.Process.setThreadPriority( + android.os.Process.THREAD_PRIORITY_BACKGROUND); + r.run(); + }, "probe"); + t.setDaemon(true); + return t; + }); + + /** A running probe; cancelled when its camera goes on screen or probing stops. */ + private static final class Probe implements StreamProbe.Cancel { + final String url; + /** {@link StreamProbe#key} of the slot's setting when the probe began. */ + final String key; + private volatile boolean cancelled; + // Guarded by this + private RtspSession session; + + Probe(String url, String key) { + this.url = url; + this.key = key; + } + + @Override public boolean cancelled() { return cancelled; } + + @Override + public synchronized boolean start(RtspSession s) { + if (cancelled) return false; + session = s; + s.start(); + return true; + } + + /** + * Ends the probe now: its socket is closed before this returns, so the camera has no + * second client once the TV connects (one-client cameras, Qodo on #163). + */ + synchronized void cancel() { + cancelled = true; + if (session != null) session.stop(); + } + } + + StreamProbes(String userAgent) { + this.userAgent = userAgent; + ((ThreadPoolExecutor) workers).allowCoreThreadTimeOut(true); + // No thread kept while the page is not in use + ((ScheduledThreadPoolExecutor) scheduler).setKeepAliveTime(30, TimeUnit.SECONDS); + ((ScheduledThreadPoolExecutor) scheduler).allowCoreThreadTimeOut(true); + } + + /** The activity goes away: probes end, the threads go (Qodo on #163). */ + void shutdown() { + synchronized (lock) { + shut = true; + } + stopAll(); + scheduler.shutdownNow(); + workers.shutdown(); + } + + /** + * The slots now (UI thread, from the debug requests): {@code wanted} in single view and the + * U view, false otherwise; {@code screenUrl} the camera on screen, or null. + */ + void update(String[] slotUrls, Transport.Mode[] slotModes, String screen, boolean want) { + synchronized (lock) { + if (shut) return; + urls = slotUrls.clone(); + modes = java.util.Arrays.copyOf(slotModes, urls.length); + String[] newKeys = new String[urls.length]; + for (int i = 0; i < urls.length; i++) newKeys[i] = StreamProbe.key(urls[i], modes[i]); + if (lastStartMs.length != urls.length) lastStartMs = new long[urls.length]; + for (int i = 0; i < urls.length; i++) { + // A changed URL or transport: the last check says nothing about it, check soon + if (i < keys.length && !java.util.Objects.equals(keys[i], newKeys[i])) { + results.remove(i); + lastStartMs[i] = 0; + } + } + keys = newKeys; + screenUrl = screen; + wanted = want; + lastUpdateMs = SystemClock.elapsedRealtime(); + cancelStaleLocked(); + if (want && ticker == null) { + ticker = scheduler.scheduleWithFixedDelay(this::tick, 0, TICK_MS, + TimeUnit.MILLISECONDS); + } + } + } + + /** The TV is about to connect to this camera: a probe of it ends now. */ + void willShow(String url) { + synchronized (lock) { + screenUrl = url; + cancelStaleLocked(); + } + } + + /** Multi-view or the app going away: running probes end now, no new ones. */ + void stopAll() { + synchronized (lock) { + wanted = false; + cancelStaleLocked(); + } + } + + /** The result for a slot, if it was found for the slot's current URL and transport. */ + StreamProbe.Result result(int slot, String url, Transport.Mode mode) { + StreamProbe.Result r = results.get(slot); + return r != null && r.key.equals(StreamProbe.key(url, mode)) ? r : null; + } + + /** The slot is being checked right now. */ + boolean isRunning(int slot) { + synchronized (lock) { + return slot < running.length && running[slot]; + } + } + + /** Ends probes that must not go on: probing stopped, or their camera is on screen now. */ + private void cancelStaleLocked() { + String screen = StreamProbes.endpoint(screenUrl); + for (int i = 0; i < probes.length; i++) { + Probe p = probes[i]; + if (p == null || p.cancelled()) continue; + String e = endpoint(p.url); + boolean slotChanged = i >= keys.length || !p.key.equals(keys[i]); + if (!wanted || slotChanged || (e != null && e.equals(screen))) p.cancel(); + } + } + + private void tick() { + synchronized (lock) { + long now = SystemClock.elapsedRealtime(); + if (!wanted || now - lastUpdateMs > IDLE_MS) { + // The page went away or the grid is back: stop asking cameras + wanted = false; + cancelStaleLocked(); + if (ticker != null) ticker.cancel(false); + ticker = null; + return; + } + int count = 0; + for (boolean r : running) if (r) count++; + if (count >= MAX_RUNNING) return; + String[] skip = new String[urls.length]; + for (int i = 0; i < urls.length; i++) skip[i] = skipReason(urls[i], null, screenUrl); + boolean[] run = java.util.Arrays.copyOf(running, urls.length); + // One new probe per tick: the checks are spread out, never started all at once + int slot = next(urls, skip, lastStartMs, run, now); + if (slot < 0) return; + lastStartMs[slot] = now; + running[slot] = true; + Probe p = new Probe(urls[slot], keys[slot]); + probes[slot] = p; + Transport.Mode mode = slot < modes.length ? modes[slot] : null; + try { + workers.execute(() -> runProbe(slot, p, mode)); + } catch (java.util.concurrent.RejectedExecutionException e) { + // Shut down meanwhile + probes[slot] = null; + running[slot] = false; + } + } + } + + private void runProbe(int slot, Probe p, Transport.Mode mode) { + StreamProbe.Result r = null; + try { + r = StreamProbe.run("Probe-" + (slot + 1), p.url, mode, userAgent, p); + } catch (RuntimeException e) { + Log.w(TAG, "Probe-" + (slot + 1) + " failed", e); + } finally { + synchronized (lock) { + if (probes[slot] == p) { + probes[slot] = null; + running[slot] = false; + } + } + } + if (r != null) { + results.put(slot, r); + Log.i(TAG, "Probe-" + (slot + 1) + ": " + (r.ok ? "stream OK, " : "") + r.summary); + } + } +} diff --git a/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java b/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java new file mode 100644 index 00000000..181241aa --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * StreamProbeTest.java — stream checks of the slots not on screen (#100) + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class StreamProbeTest { + private static final String DEF = "rtsp://root:12345@192.168.1.10:554/stream=0"; + + /** RTP packet: version 2, payload type, marker, then the payload bytes. */ + private static byte[] rtp(int pt, boolean marker, int... payload) { + byte[] p = new byte[12 + payload.length]; + p[0] = (byte) 0x80; + p[1] = (byte) ((marker ? 0x80 : 0) | pt); + for (int i = 0; i < payload.length; i++) p[12 + i] = (byte) payload[i]; + return p; + } + + private static StreamProbe.Counter counter(int pt, boolean h265, boolean jpeg) { + StreamProbe.Counter c = new StreamProbe.Counter(); + Sdp sdp = new Sdp(); + sdp.videoPt = pt; + sdp.videoH265 = h265; + sdp.videoJpeg = jpeg; + c.onSdp(sdp); + return c; + } + + private static void feed(StreamProbe.Counter c, byte[] p, long ms) { + c.onPacket(p, 0, p.length, ms); + } + + @Test + public void framesAreCountedByTheMarkerBit() { + StreamProbe.Counter c = counter(96, false, false); + for (int i = 0; i < 26; i++) { + feed(c, rtp(96, false, 0x41, 1, 2), i * 40); + feed(c, rtp(96, true, 0x41, 3, 4), i * 40); + } + assertEquals(52, c.packets); + assertEquals(26, c.frames); + assertEquals(25f, c.fps(), 0.01f); + assertEquals("H.264 · 25 fps · 6 kbit/s", c.summary()); + } + + @Test + public void otherPayloadTypesAndEmptyPacketsAreIgnored() { + StreamProbe.Counter c = counter(96, false, false); + byte[] other = rtp(97, true, 0x65); + assertFalse(c.onPacket(other, 0, other.length, 0)); + // A marked header without payload is not a frame (Qodo on #163) + byte[] empty = rtp(96, true); + assertFalse(c.onPacket(empty, 0, empty.length, 0)); + byte[] p = rtp(96, true, 0x41); + assertTrue(c.onPacket(p, 0, p.length, 0)); + assertEquals(1, c.packets); + assertEquals(1, c.frames); + assertEquals("PLAY OK, but no video", + StreamProbe.result("k", 0, true, null, counter(96, false, false)).summary); + } + + @Test + public void h264KeyFramesAloneFragmentedOrAggregated() { + StreamProbe.Counter c = counter(96, false, false); + // IDR alone + feed(c, rtp(96, true, 0x65, 0), 0); + // FU-A carrying an IDR: start piece, then the end piece + feed(c, rtp(96, false, 0x7C, 0x85, 0), 40); + feed(c, rtp(96, true, 0x7C, 0x45, 0), 40); + // STAP-A with SPS and IDR + feed(c, rtp(96, true, 0x78, 0, 2, 0x67, 0, 0, 2, 0x65, 0), 80); + // A P frame + feed(c, rtp(96, true, 0x41, 0), 120); + assertEquals(4, c.frames); + assertEquals(3, c.keyFrames); + } + + @Test + public void h265KeyFrames() { + StreamProbe.Counter c = counter(97, true, false); + // IDR_W_RADL (19) alone: header (19 << 1), 1 + feed(c, rtp(97, true, 19 << 1, 1, 0), 0); + // FU (49) with the start bit and type 19 + feed(c, rtp(97, true, 49 << 1, 1, 0x80 | 19, 0), 40); + // TRAIL_R (1) + feed(c, rtp(97, true, 1 << 1, 1, 0), 80); + assertEquals(3, c.frames); + assertEquals(2, c.keyFrames); + assertEquals("H.265", c.codec()); + } + + @Test + public void rtpJpegSizeFromItsHeader() { + StreamProbe.Counter c = counter(26, false, true); + // type-specific, offset (3), type, Q, width / 8, height / 8 + feed(c, rtp(26, true, 0, 0, 0, 0, 1, 255, 160, 90), 0); + feed(c, rtp(26, true, 0, 0, 0, 0, 1, 255, 160, 90), 200); + assertEquals(1280, c.width); + assertEquals(720, c.height); + assertTrue(c.summary(), c.summary().startsWith("1280×720 MJPEG · 5.0 fps")); + } + + @Test + public void httpMjpegFramesAndSize() { + StreamProbe.Counter c = new StreamProbe.Counter(); + byte[] jpeg = JpegAssembler.headers(1, 640, 480, JpegAssembler.tables(50), 0); + assertTrue(c.onJpegFrame(jpeg, 0, jpeg.length, 0)); + assertTrue(c.onJpegFrame(jpeg, 0, jpeg.length, 100)); + // Not a JPEG: no frame (Qodo on #163) + byte[] html = "Login".getBytes(); + assertFalse(c.onJpegFrame(html, 0, html.length, 150)); + // SOI with neither a frame header nor EOI + byte[] cut = {(byte) 0xFF, (byte) 0xD8, 0, 0, 0, 0}; + assertFalse(c.onJpegFrame(cut, 0, cut.length, 150)); + // SOI ... EOI is enough + byte[] tiny = {(byte) 0xFF, (byte) 0xD8, 1, 2, (byte) 0xFF, (byte) 0xD9, '\r', '\n'}; + assertTrue(c.onJpegFrame(tiny, 0, tiny.length, 200)); + assertEquals(3, c.frames); + c = new StreamProbe.Counter(); + c.onJpegFrame(jpeg, 0, jpeg.length, 0); + c.onJpegFrame(jpeg, 0, jpeg.length, 100); + assertEquals(2, c.frames); + assertEquals(640, c.width); + assertEquals(480, c.height); + assertEquals("MJPEG", c.codec()); + assertEquals(10f, c.fps(), 0.01f); + } + + @Test + public void resultTexts() { + StreamProbe.Result noAnswer = StreamProbe.result("u", 1000, false, null, new StreamProbe.Counter()); + assertFalse(noAnswer.ok); + assertEquals("No answer from camera · checked 12 s ago", noAnswer.text(13_000)); + StreamProbe.Result login = StreamProbe.result("u", 0, false, "RTSP DESCRIBE failed: 401", null); + assertEquals("Wrong login or password", login.summary); + StreamProbe.Result silent = StreamProbe.result("u", 0, true, null, counter(96, false, false)); + assertEquals("PLAY OK, but no video", silent.summary); + StreamProbe.Counter c = counter(96, false, false); + feed(c, rtp(96, true, 0x65), 0); + feed(c, rtp(96, true, 0x41), 1000); + StreamProbe.Result r = StreamProbe.result("u", 0, true, null, c); + assertTrue(r.ok); + assertTrue(r.text(180_000), r.text(180_000).startsWith("Stream OK · H.264 · 1.0 fps")); + assertTrue(r.text(180_000).endsWith("checked 3 min ago")); + assertEquals(1, r.keyFrames); + } + + @Test + public void resultsBelongToTheUrlAndTheTransportOfTheCheck() { + assertEquals("TCP rtsp://a/1", StreamProbe.key("rtsp://a/1", Transport.Mode.AUTO)); + assertEquals("TCP rtsp://a/1", StreamProbe.key("rtsp://a/1", null)); + assertEquals("UDP rtsp://a/1", StreamProbe.key("rtsp://a/1", Transport.Mode.UDP)); + assertNull(StreamProbe.key(null, Transport.Mode.UDP)); + StreamProbes probes = new StreamProbes(""); + assertNull(probes.result(0, "rtsp://a/1", Transport.Mode.TCP)); + } + + @Test + public void skipReasons() { + assertEquals("not configured", StreamProbes.skipReason(null, DEF, null)); + assertEquals("not configured", StreamProbes.skipReason(DEF, DEF, null)); + assertEquals("UDP listen slot: not checked in single view", + StreamProbes.skipReason("udp://@:5600", DEF, null)); + assertEquals("same camera as on screen", StreamProbes.skipReason( + "rtsp://a:b@10.0.0.53:554/x", DEF, "rtsp://10.0.0.53/y")); + assertNull(StreamProbes.skipReason("rtsp://10.0.0.53:8554/x", DEF, "rtsp://10.0.0.53/y")); + assertNull(StreamProbes.skipReason("http://u:p@10.0.0.133/mjpeg", DEF, "rtsp://10.0.0.133/y")); + assertEquals("same camera as on screen", StreamProbes.skipReason( + "http://10.0.0.133/mjpeg", DEF, "http://u:p@10.0.0.133:80/other")); + } + + @Test + public void endpoints() { + assertEquals("10.0.0.5:554", StreamProbes.endpoint("rtsp://u:p@10.0.0.5/stream=0")); + assertEquals("cam:8554", StreamProbes.endpoint("rtsp://CAM:8554/x")); + assertEquals("10.0.0.5:80", StreamProbes.endpoint("http://10.0.0.5/mjpeg")); + assertNull(StreamProbes.endpoint("udp://@:5600")); + assertNull(StreamProbes.endpoint(null)); + } + + @Test + public void nextSlotWaitsLongestAndNeverTwiceToOneCamera() { + String[] urls = {"rtsp://a/1", "rtsp://b/1", "rtsp://a/2", "rtsp://c/1"}; + String[] skip = new String[4]; + long[] last = new long[4]; + boolean[] running = new boolean[4]; + // Never checked: the lowest slot first + assertEquals(0, StreamProbes.next(urls, skip, last, running, 100_000)); + // Slot 0 runs: slot 2 is the same camera, so slot 1 + running[0] = true; + last[0] = 100_000; + assertEquals(1, StreamProbes.next(urls, skip, last, running, 100_000)); + running[1] = true; + last[1] = 100_000; + assertEquals(3, StreamProbes.next(urls, skip, last, running, 100_000)); + // All done; slot 2 never checked comes first, then the oldest once the period passed + running[0] = running[1] = false; + last[3] = 101_000; + assertEquals(2, StreamProbes.next(urls, skip, last, running, 102_000)); + last[2] = 102_000; + assertEquals(-1, StreamProbes.next(urls, skip, last, running, 102_000 + 1000)); + assertEquals(0, StreamProbes.next(urls, skip, last, running, + 100_000 + StreamProbes.PERIOD_MS)); + // A skipped slot is never chosen + skip[0] = "same camera as on screen"; + assertEquals(1, StreamProbes.next(urls, skip, last, running, + 100_000 + StreamProbes.PERIOD_MS)); + } +}