Я купил на AliExpress небольшую заднюю камеру с Wi-Fi 12 В. Похоже, что в камере используется чип AC54 или AC51 от JieLi Technology.
Устройство открывает точку доступа Wi-Fi на канале 3 и получает IP-адрес 192.168.1.1.
Вы можете подключиться к Wi-Fi камеры и загрузить файл описания с сайта http://192.168.1.1:8080/mnt/spiflash/res/dev_desc.txt, который имеет вид json:
Затем я прослушивал связь между мобильным приложением и камерой. Он использует TCP-канал (порт 3333) для команд и отправляет UDP-пакеты на порт 2224 при активации видеопотока, как я наблюдал в Wireshark.
Затем я написал небольшое тестовое приложение на Kotlin для своего устройства Android для чтения видеопотока. Однако в моем приложении всегда появляются поврежденные изображения. Официальное приложение-компаньон камеры работает нормально, но я хочу использовать свою собственную реализацию. К сожалению, мне не удалось найти загружаемый JLDV16SDK для этой камеры.
Устройство отправляет поток MJPEG (JFIF JPEG), но его кадры фрагментированы, и я подозреваю, что оно использует собственный формат.
Вот несколько документов для этого типа устройств (в основном на китайском языке, но инструменты перевода делают их в основном читабельными):
Первые 20 байтов каждой полезной нагрузки представляют собой собственный заголовок. Затем я попытался реализовать это в классе RTPFrameAssembler. Фрагменты, отмеченные HasJPEG = YES, содержат маркер SOI (
), поэтому сами фреймы действительны. Кадры собираются с использованием смещений фрагментов, а затем визуализируются на поверхности. Моя проблема в том, что полученные файлы JPEG повреждены. Я что-то упустил? Edit#3
обновляет источник в сообщении и добавляет пример изображения результата:
!(https://i.sstatic.net/vcHgTVo7.jpg)
Я купил на AliExpress небольшую заднюю камеру с Wi-Fi 12 В. Похоже, что в камере используется чип AC54 или AC51 от JieLi Technology. Устройство открывает точку доступа Wi-Fi на канале 3 и получает IP-адрес 192.168.1.1. Вы можете подключиться к Wi-Fi камеры и загрузить файл описания с сайта http://192.168.1.1:8080/mnt/spiflash/res/dev_desc.txt, который имеет вид json: [code]{ "app_list": { "match_android_ver": [ "1.0", "2.0" ], "match_ios_ver": [ "1.0", "2.0" ] }, "forward_support": [ "0", "1" ], "behind_support": [ "0" ], "forward_record_support": [ "0", "1" ], "behind_record_support": [ "0" ], "rtsp_forward_support": [ "0", "1" ], "rtsp_behind_support": [ "0" ], "device_type": "1", "net_type": "1", "rts_type": "0", "product_type": "AC521x_wifi_car_camera", "support_projection": "0", "firmware_version": "1.0.1", "match_app_type": "DVRunning 2", "uuid": "xxxxxx" } [/code] Затем я прослушивал связь между мобильным приложением и камерой. Он использует TCP-канал (порт 3333) для команд и отправляет UDP-пакеты на порт 2224 при активации видеопотока, как я наблюдал в Wireshark. Затем я написал небольшое тестовое приложение на Kotlin для своего устройства Android для чтения видеопотока. Однако в моем приложении всегда появляются поврежденные изображения. Официальное приложение-компаньон камеры работает нормально, но я хочу использовать свою собственную реализацию. К сожалению, мне не удалось найти загружаемый JLDV16SDK для этой камеры. Устройство отправляет поток MJPEG (JFIF JPEG), но его кадры фрагментированы, и я подозреваю, что оно использует собственный формат. Вот несколько документов для этого типа устройств (в основном на китайском языке, но инструменты перевода делают их в основном читабельными): [list] [*]https://doc.zh-jieli.com/Apps/iOS/video/zh-cn/v2.5.8/Framework/framework.html
[/list] Забавное примечание: тот же чип используется и в дроне. [list] [*]https://particolarmente-urgentissimo.blogspot.com/2021/01/sniffaggio-traffico-wifi-drone-1-parte.html [/list] [b]РЕДАКТИРОВАТЬ:[/b] [b]Я загрузил тестовое приложение в свою учетную запись GitHub:[/b]
https://github.com/mightymop/camtest/tree/main Основная логика, отвечающая за получение и восстановление видеопотока, находится здесь:
interface StreamListener { fun onVideoStarted() fun onVideoStopped() fun onError(error: String) fun onFrameDecoded(width: Int, height: Int) fun onStreamInfo(info: String) fun onPcapDumpStarted(filePath: String) fun onPcapDumpStopped(filePath: String, packetCount: Int) }
private var udpSocket: DatagramSocket? = null private val isReceiving = AtomicBoolean(false) private var listener: StreamListener? = null private var surfaceHolder: SurfaceHolder? = null private var paint: Paint = Paint()
// Performance Optimierungen private val packetQueue = LinkedBlockingQueue(100) // Buffer für Pakete private lateinit var frameAssembler: RTPFrameAssembler private lateinit var context: Context
// Frame-Rate Limiting private var lastFrameTime = 0L private val targetFrameTime = 33L // ~30 FPS private var framesProcessed = 0 private var framesSkipped = 0
private var pcapDumpEnabled: Boolean = false private var pcapFile: RandomAccessFile? = null private var pcapPacketCount = 0
private val DEBUG = false
companion object { private const val TAG = "JFIFMJpegStreamReceiver" private const val BUFFER_SIZE = 65536 }
// Source IP val src = ipStringToBytes(sourceIp) buffer.put(src)
// Destination IP (fake) buffer.put(192.toByte()); buffer.put(168.toByte()); buffer.put(1.toByte()); buffer.put(100.toByte())
// Compute checksum over the 20 byte header val headerBytes = buffer.array() val checksum = ipChecksum(headerBytes) // write checksum (big-endian) into bytes 10..11 headerBytes[10] = ((checksum ushr 8) and 0xFF).toByte() headerBytes[11] = (checksum and 0xFF).toByte()
return headerBytes }
private fun createUdpHeader(dataLength: Int, sourcePort: Int): ByteArray { // UDP header 8 bytes val buffer = ByteBuffer.allocate(8) buffer.order(ByteOrder.BIG_ENDIAN)
val destPort = try { // fallback if resource not available; ersetze durch deine Resource-Lookup-Variante falls vorhanden context.resources.getInteger(R.integer.udpport) } catch (ex: Exception) { 5004 // default }
buffer.putShort((sourcePort and 0xFFFF).toShort()) buffer.putShort((destPort and 0xFFFF).toShort()) buffer.putShort(((8 + dataLength) and 0xFFFF).toShort()) buffer.putShort(0x0000.toShort()) // checksum 0 (optional for IPv4)
return buffer.array() }
// --- Hilfsfunktionen --- private fun ipStringToBytes(ip: String): ByteArray { val parts = ip.split(".") val out = ByteArray(4) for (i in 0 until 4) { val v = if (i < parts.size) parts[i].toIntOrNull() ?: 0 else 0 out[i] = (v and 0xFF).toByte() } return out }
/** Berechnet die IP-Header-Checksumme (16-bit ones complement sum) */ private fun ipChecksum(header: ByteArray): Int { var sum = 0 var i = 0 while (i < header.size) { val hi = header[i].toInt() and 0xFF val lo = header[i + 1].toInt() and 0xFF val word = (hi shl 8) or lo sum += word if (sum > 0xFFFF) { sum = (sum and 0xFFFF) + (sum ushr 16) } i += 2 } // one's complement sum = sum.inv() and 0xFFFF return sum }
private fun dumpPacketToPcap(data: ByteArray, sourceIp: String, sourcePort: Int) { try { val now = System.currentTimeMillis() val tsSec = (now / 1000L).toInt() val tsUsec = ((now % 1000L) * 1000L).toInt()
// Ethernet + IP + UDP header erzeugen val eth = createEthernetHeader() val ip = createIpHeader(data.size, sourceIp) val udp = createUdpHeader(data.size, sourcePort)
// Packet zusammenbauen val packetData = ByteArray(eth.size + ip.size + udp.size + data.size) var off = 0 System.arraycopy(eth, 0, packetData, off, eth.size); off += eth.size System.arraycopy(ip, 0, packetData, off, ip.size); off += ip.size System.arraycopy(udp, 0, packetData, off, udp.size); off += udp.size System.arraycopy(data, 0, packetData, off, data.size)
val inclLen = packetData.size val origLen = inclLen
fun decodeAndLogExif(frameData: ByteArray) { val options = BitmapFactory.Options() val bitmap = BitmapFactory.decodeByteArray(frameData, 0, frameData.size, options)
if (bitmap != null) { // --- EXIF AUSLESEN --- try { val exif = ExifInterface(ByteArrayInputStream(frameData))
// ------------------- Optimized Frame Assembler ------------------- class RTPFrameAssembler(private val context: Context) {
private val activeFrames = mutableMapOf() private val frameTimeout = 1000L private var framesAssembled = 0
data class FrameKey(val frameId: Int, val timestamp: Int) data class FrameAssembly( val fragments: MutableMap = mutableMapOf(), var lastUpdate: Long = System.currentTimeMillis() )
fun processPacket(packet: ByteArray): ByteArray? { val rtpInfo = parseRTPHeader(packet) ?: return null val (frameId, timestamp, fragmentOffset) = rtpInfo
val payload = packet.copyOfRange(20, packet.size) val frameKey = FrameKey(frameId, timestamp)
// Clean up old frames cleanupOldFrames()
// Add fragment to frame val frameAssembly = activeFrames.getOrPut(frameKey) { FrameAssembly() } frameAssembly.fragments[fragmentOffset] = payload frameAssembly.lastUpdate = System.currentTimeMillis()
// Check if frame is complete if (isFrameComplete(frameAssembly)) { val frameData = assembleJpegFrame(frameAssembly.fragments) activeFrames.remove(frameKey) framesAssembled++ return frameData }
private fun assembleJpegFrame(fragments: Map): ByteArray { if (fragments.isEmpty()) return ByteArray(0)
val sorted = fragments.toSortedMap() val lastOffset = sorted.keys.last() val lastSize = sorted[lastOffset]!!.size val buffer = ByteArray(lastOffset + lastSize)
for ((offset, fragment) in sorted) { System.arraycopy(fragment, 0, buffer, offset, fragment.size) }
return trimToEOF(buffer) }
private fun trimToEOF(data: ByteArray): ByteArray { for (i in 0 until data.size - 1) { if (data[i] == 0xFF.toByte() && data[i + 1] == 0xD9.toByte()) { return data.copyOfRange(0, i + 2) } } return data }
private fun hasSOIMarker(data: ByteArray): Boolean { for (i in 0 until data.size - 1) { if (data[i].toInt() and 0xFF == 0xFF && data[i + 1].toInt() and 0xFF == 0xD8) { return true } } return false }
private fun hasEOFMarker(data: ByteArray): Boolean { for (i in 0 until data.size - 1) { if (data[i].toInt() and 0xFF == 0xFF && data[i + 1].toInt() and 0xFF == 0xD9) { return true } } return false }
private fun cleanupOldFrames() { val now = System.currentTimeMillis() val toRemove = activeFrames.filter { (_, frame) -> now - frame.lastUpdate > frameTimeout }.keys toRemove.forEach { activeFrames.remove(it) } }
private fun parseRTPHeader(data: ByteArray): RTPInfo? { if (data.size < 20) return null return try { // 4-Byte Sequence (Little Endian) val frameId = (data[4].toInt() and 0xFF) or ((data[5].toInt() and 0xFF) shl 8) or ((data[6].toInt() and 0xFF) shl 16) or ((data[7].toInt() and 0xFF) shl 24)
// 4-Byte Timestamp (Little Endian) val timestamp = (data[8].toInt() and 0xFF) or ((data[9].toInt() and 0xFF) shl 8) or ((data[10].toInt() and 0xFF) shl 16) or ((data[11].toInt() and 0xFF) shl 24)
// 2-Byte Fragment Offset (Little Endian) val fragmentOffset = (data[12].toInt() and 0xFF) or ((data[13].toInt() and 0xFF) shl 8)
[/code] Первые 20 байтов каждой полезной нагрузки представляют собой собственный заголовок. Затем я попытался реализовать это в классе RTPFrameAssembler. Фрагменты, отмеченные HasJPEG = YES, содержат маркер SOI ([code]FF D8[/code]), а фрагменты с наибольшим смещением содержат маркер EOF ([code]FF D9[/code]), поэтому сами фреймы действительны. Кадры собираются с использованием смещений фрагментов, а затем визуализируются на поверхности. Моя проблема в том, что полученные файлы JPEG повреждены. Я что-то упустил? [b]Edit#3[/b] обновляет источник в сообщении и добавляет пример изображения результата: !(https://i.sstatic.net/vcHgTVo7.jpg)