Quellcode durchsuchen

feat: 摄像头配置管理完善与流会话测试

- 摄像头配置接口(camera_config)与前端摄像头管理页
- MJPEG 流会话订阅/状态测试补齐
lq vor 9 Stunden
Ursprung
Commit
b907f34689

+ 24 - 0
frontend/src/api/camera.js

@@ -4,3 +4,27 @@ export const getCameraStreams = () => service({
   url: '/parking/camera/streams',
   method: 'get'
 })
+
+export const obtainCameraList = (params) => service({
+  url: '/parking/camera/list',
+  method: 'get',
+  params,
+})
+
+export const addCamera = (data) => service({
+  url: '/parking/camera/create',
+  method: 'post',
+  data,
+})
+
+export const editCamera = (data) => service({
+  url: '/parking/camera/update',
+  method: 'put',
+  data,
+})
+
+export const deleteCamera = (id) => service({
+  url: '/parking/camera/delete',
+  method: 'delete',
+  params: { id },
+})

+ 175 - 0
frontend/src/view/parking/parkingInfo/camera.vue

@@ -0,0 +1,175 @@
+<template>
+  <div v-loading="loading">
+    <div class="camera-toolbar">
+      <div class="camera-toolbar-copy">
+        <span class="camera-toolbar-title">摄像头与视频流</span>
+        <span class="camera-toolbar-note">绑定通道后,实时画面会显示在进出场操作页。</span>
+      </div>
+      <div class="camera-toolbar-actions">
+        <el-button type="success" icon="Plus" @click="openDialog('add')">新增摄像头</el-button>
+        <el-button icon="Refresh" @click="fetchData">刷新</el-button>
+      </div>
+    </div>
+
+    <el-table :data="list" size="small" height="505" border stripe>
+      <el-table-column label="摄像头" min-width="170">
+        <template #default="{ row }">
+          <div class="camera-name">{{ row.device_name }}</div>
+          <div class="camera-code">{{ row.device_code }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="绑定通道" min-width="150">
+        <template #default="{ row }">{{ row.channel_binding?.channel_name || `通道 #${row.channel_id}` }}</template>
+      </el-table-column>
+      <el-table-column label="设备地址" min-width="160">
+        <template #default="{ row }">{{ row.ip_address }}:{{ row.port }}</template>
+      </el-table-column>
+      <el-table-column label="播放方式" width="130">
+        <template #default="{ row }">
+          <el-tag :type="row.stream_protocol === 'system-mjpeg' ? 'success' : 'info'">
+            {{ protocolLabel(row.stream_protocol) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="启用" width="80" align="center">
+        <template #default="{ row }"><el-tag :type="row.is_active ? 'success' : 'info'">{{ row.is_active ? '是' : '否' }}</el-tag></template>
+      </el-table-column>
+      <el-table-column label="状态" width="90" align="center">
+        <template #default="{ row }"><el-tag :type="isOnline(row.status) ? 'success' : 'danger'">{{ isOnline(row.status) ? '在线' : '离线' }}</el-tag></template>
+      </el-table-column>
+      <el-table-column label="操作" width="170" fixed="right" align="center">
+        <template #default="{ row }">
+          <el-button text type="primary" icon="Edit" @click="openDialog('edit', row)">配置</el-button>
+          <el-button text type="danger" icon="Delete" @click="handleDelete(row.ID)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div class="camera-pagination">
+      <el-pagination v-model:current-page="query.page" v-model:page-size="query.page_size" :page-sizes="[10, 30, 50, 100]" :total="total" layout="total, sizes, prev, pager, next, jumper" @current-change="fetchData" @size-change="onSizeChange" />
+    </div>
+
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '配置摄像头' : '新增摄像头'" width="760" :close-on-click-modal="false">
+      <el-alert type="info" :closable="false" show-icon>
+        <template #title>系统转换使用 RTSP/HTTP 原始地址,经本机 FFmpeg 转为浏览器可播放画面;边缘转换则填写边缘端提供的播放地址。</template>
+      </el-alert>
+      <el-form label-width="128" class="camera-form">
+        <div class="form-grid">
+          <el-form-item label="摄像头编码" required><el-input v-model.trim="form.device_code" placeholder="例如 CAM-IN-01" /></el-form-item>
+          <el-form-item label="摄像头名称" required><el-input v-model.trim="form.device_name" placeholder="例如 入口摄像头" /></el-form-item>
+          <el-form-item label="绑定通道" required>
+            <el-select v-model="form.channel_id" placeholder="请选择通道" style="width: 100%"><el-option v-for="item in channelList" :key="item.ID" :label="`${item.channel_name} (${directionLabel(item.direction)})`" :value="item.ID" /></el-select>
+          </el-form-item>
+          <el-form-item label="摄像头 IP" required><el-input v-model.trim="form.ip_address" placeholder="例如 192.168.110.67" /></el-form-item>
+          <el-form-item label="RTSP 端口" required><el-input-number v-model="form.port" :min="1" :max="65535" controls-position="right" style="width: 100%" /></el-form-item>
+          <el-form-item label="设备通道号"><el-input-number v-model="form.channel" :min="1" :max="999" controls-position="right" style="width: 100%" /></el-form-item>
+        </div>
+        <el-form-item label="播放方式" required>
+          <el-radio-group v-model="form.stream_protocol">
+            <el-radio value="system-mjpeg">系统端转换</el-radio>
+            <el-radio value="webrtc">边缘 WebRTC</el-radio>
+            <el-radio value="hls">边缘 HLS</el-radio>
+            <el-radio value="mp4">MP4 地址</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-if="form.stream_protocol === 'system-mjpeg'" label="RTSP/HTTP 原始地址" required>
+          <el-input v-model.trim="form.source_url" placeholder="rtsp://用户名:密码@192.168.110.67:554/实际视频路径;编辑时保留掩码即可" />
+        </el-form-item>
+        <el-form-item v-else label="浏览器播放地址" required>
+          <el-input v-model.trim="form.stream_url" placeholder="例如 https://edge.example.com/live/whep 或 https://.../index.m3u8" />
+        </el-form-item>
+        <el-form-item label="抓拍地址"><el-input v-model.trim="form.snapshot_url" placeholder="可选,用于抓拍图片接口" /></el-form-item>
+        <el-form-item label="启用"><el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" /></el-form-item>
+        <el-form-item label="备注"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
+      </el-form>
+      <template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitForm">保存配置</el-button></template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { addCamera, deleteCamera, editCamera, obtainCameraList } from '@/api/camera'
+import { obtainChannelList } from '@/api/channel'
+
+const props = defineProps({ parkId: { type: Number, default: 0 } })
+const loading = ref(false)
+const saving = ref(false)
+const list = ref([])
+const total = ref(0)
+const channelList = ref([])
+const dialogVisible = ref(false)
+const isEdit = ref(false)
+const query = reactive({ parking_lot_id: 0, page: 1, page_size: 10 })
+const form = reactive({ id: 0, device_code: '', device_name: '', channel_id: null, ip_address: '', port: 554, username: '', password: '', channel: 1, is_active: true, stream_protocol: 'system-mjpeg', source_url: '', stream_url: '', snapshot_url: '', description: '' })
+
+const protocolLabel = (protocol) => ({ 'system-mjpeg': '系统端转换', webrtc: '边缘 WebRTC', hls: '边缘 HLS', mp4: 'MP4 地址' }[protocol] || protocol)
+const directionLabel = (direction) => ({ in: '进场', out: '出场', inout: '进出' }[direction] || direction)
+const isOnline = (status) => status === 'online' || status === 'connected'
+
+const fetchData = async () => {
+  if (!props.parkId) { list.value = []; total.value = 0; return }
+  loading.value = true
+  try {
+    query.parking_lot_id = props.parkId
+    const res = await obtainCameraList({ ...query })
+    if (res.code === 0) { list.value = res.data.list || []; total.value = res.data.total || 0 }
+  } finally { loading.value = false }
+}
+
+const fetchChannels = async () => {
+  const res = await obtainChannelList({ parking_lot_id: props.parkId, page: 1, page_size: 100 })
+  if (res.code === 0) channelList.value = res.data.list || []
+}
+
+const resetForm = () => Object.assign(form, { id: 0, device_code: '', device_name: '', channel_id: null, ip_address: '', port: 554, username: '', password: '', channel: 1, is_active: true, stream_protocol: 'system-mjpeg', source_url: '', stream_url: '', snapshot_url: '', description: '' })
+
+const openDialog = async (mode, row) => {
+  if (!props.parkId) { ElMessage.warning('请先选择停车场'); return }
+  await fetchChannels()
+  isEdit.value = mode === 'edit'
+  resetForm()
+  if (row) Object.assign(form, { id: row.ID, device_code: row.device_code, device_name: row.device_name, channel_id: row.channel_id, ip_address: row.ip_address, port: row.port, username: row.username, channel: row.channel || 1, is_active: row.is_active, stream_protocol: row.stream_protocol || 'system-mjpeg', source_url: row.source_url, stream_url: row.stream_url, snapshot_url: row.snapshot_url, description: row.description })
+  dialogVisible.value = true
+}
+
+const submitForm = async () => {
+  if (!form.device_code || !form.device_name || !form.channel_id || !form.ip_address) { ElMessage.error('请填写摄像头编码、名称、IP 并绑定通道'); return }
+  if (form.stream_protocol === 'system-mjpeg' && !form.source_url) { ElMessage.error('系统端转换必须填写 RTSP/HTTP 原始地址'); return }
+  if (form.stream_protocol !== 'system-mjpeg' && !form.stream_url) { ElMessage.error('边缘播放模式必须填写浏览器播放地址'); return }
+  saving.value = true
+  try {
+    const res = isEdit.value ? await editCamera(form) : await addCamera(form)
+    if (res.code !== 0) throw new Error(res.msg || '保存失败')
+    ElMessage.success('摄像头配置已保存')
+    dialogVisible.value = false
+    await fetchData()
+  } catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false }
+}
+
+const handleDelete = (id) => ElMessageBox.confirm('删除后进出场页面将不再显示此摄像头,确定继续吗?', '删除摄像头', { type: 'warning' }).then(async () => {
+  const res = await deleteCamera(id)
+  if (res.code === 0) { ElMessage.success('摄像头已删除'); await fetchData() } else ElMessage.error(res.msg || '删除失败')
+}).catch(() => {})
+
+const onSizeChange = (size) => { query.page_size = size; query.page = 1; fetchData() }
+watch(() => props.parkId, () => { query.page = 1; fetchData() }, { immediate: true })
+
+let refreshTimer
+onMounted(() => { refreshTimer = window.setInterval(fetchData, 10000) })
+onUnmounted(() => { if (refreshTimer) window.clearInterval(refreshTimer) })
+</script>
+
+<style scoped lang="scss">
+.camera-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; padding: 12px 14px; border-left: 3px solid #0d9b8a; background: #f4fbfa; }
+.camera-toolbar-copy { display: grid; gap: 3px; min-width: 0; }
+.camera-toolbar-title { color: #183d45; font-size: 15px; font-weight: 650; }
+.camera-toolbar-note, .camera-code { color: #71808a; font-size: 12px; }
+.camera-toolbar-actions, .camera-pagination { display: flex; justify-content: flex-end; gap: 8px; }
+.camera-pagination { margin-top: 10px; }
+.camera-name { color: #26363d; font-weight: 600; }
+.camera-form { margin-top: 18px; }
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 16px; }
+@media (max-width: 760px) { .camera-toolbar { align-items: flex-start; flex-direction: column; } .form-grid { grid-template-columns: 1fr; } }
+</style>

+ 4 - 0
frontend/src/view/parking/parkingInfo/index.vue

@@ -49,6 +49,9 @@
         <el-tab-pane label="设备管理" name="device">
           <device :park-id="parkStore.parkId" />
         </el-tab-pane>
+        <el-tab-pane label="摄像头管理" name="camera">
+          <camera :park-id="parkStore.parkId" />
+        </el-tab-pane>
       </el-tabs>
     </el-col>
 
@@ -110,6 +113,7 @@ import lotDetail from './lotDetail.vue'
 import booth from './booth.vue'
 import channel from './channel.vue'
 import device from './device.vue'
+import camera from './camera.vue'
 
 const parkStore = useParkAreaStore()
 const activeTab = ref('detail')

+ 1 - 1
internal/api/v1/parking/camera.go

@@ -20,7 +20,7 @@ func CameraMJPEG(c *gin.Context) {
 	c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
 	c.Header("Pragma", "no-cache")
 	err := passageService.StreamCameraMJPEG(c.Param("device_code"), c.Param("token"), c.Writer, c.Request.Context())
-	if err != nil {
+	if err != nil && !c.Writer.Written() {
 		c.Status(404)
 		return
 	}

+ 64 - 0
internal/api/v1/parking/camera_config.go

@@ -0,0 +1,64 @@
+package parking
+
+import (
+	"fmt"
+
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/model/parking/request"
+
+	"github.com/gin-gonic/gin"
+)
+
+// CreateCamera 保存摄像头和原始 RTSP/HTTP 播放配置。
+func CreateCamera(c *gin.Context) {
+	var req request.CameraCreate
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := passageService.CreateCamera(req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("摄像头已创建", c)
+}
+
+func UpdateCamera(c *gin.Context) {
+	var req request.CameraUpdate
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := passageService.UpdateCamera(req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("摄像头配置已更新", c)
+}
+
+func ListCameras(c *gin.Context) {
+	var req request.CameraQuery
+	if err := c.ShouldBindQuery(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	total, list, err := passageService.ListCameras(req)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: req.Page, PageSize: req.PageSize}, "查询成功", c)
+}
+
+func DeleteCamera(c *gin.Context) {
+	var id uint
+	if _, err := fmt.Sscanf(c.Query("id"), "%d", &id); err != nil || id == 0 {
+		response.FailWithMessage("无效的ID", c)
+		return
+	}
+	if err := passageService.DeleteCamera(id); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("摄像头已删除", c)
+}

+ 32 - 0
internal/model/parking/request/camera.go

@@ -0,0 +1,32 @@
+package request
+
+// CameraCreate 是摄像头配置创建请求。原始播放地址只在系统侧转码时使用。
+type CameraCreate struct {
+	DeviceCode     string `json:"device_code" binding:"required,max=50"`
+	DeviceName     string `json:"device_name" binding:"required,max=100"`
+	ChannelID      uint   `json:"channel_id" binding:"required"`
+	IPAddress      string `json:"ip_address" binding:"required,max=50"`
+	Port           int    `json:"port" binding:"required,min=1,max=65535"`
+	Username       string `json:"username"`
+	Password       string `json:"password"`
+	Channel        int    `json:"channel" binding:"min=1"`
+	IsActive       bool   `json:"is_active"`
+	StreamProtocol string `json:"stream_protocol" binding:"required,oneof=system-mjpeg webrtc hls mp4"`
+	SourceURL      string `json:"source_url"`
+	StreamURL      string `json:"stream_url"`
+	SnapshotURL    string `json:"snapshot_url"`
+	Description    string `json:"description"`
+}
+
+// CameraUpdate 是摄像头配置更新请求。密码留空时保留已保存的密码。
+type CameraUpdate struct {
+	ID uint `json:"id" binding:"required"`
+	CameraCreate
+}
+
+// CameraQuery 按停车场筛选摄像头列表。
+type CameraQuery struct {
+	ParkingLotID uint `form:"parking_lot_id"`
+	Page         int  `form:"page,default=1" binding:"min=1"`
+	PageSize     int  `form:"page_size,default=10" binding:"min=1,max=100"`
+}

+ 130 - 5
internal/service/parking/camera.go

@@ -8,6 +8,7 @@ import (
 
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
+	"wails-app/internal/model/parking/request"
 )
 
 // CameraStreamOption 是工作台使用的安全摄像头信息,不包含用户名和密码。
@@ -24,6 +25,117 @@ type CameraStreamOption struct {
 	Online      bool   `json:"online"`
 }
 
+// CreateCamera 创建摄像头配置,并校验它绑定的通道。
+func (s *PassageService) CreateCamera(req request.CameraCreate) error {
+	if err := validateCameraConfig(req); err != nil {
+		return err
+	}
+	var existing dao.Camera
+	if global.GVA_DB.Where("device_code = ?", req.DeviceCode).First(&existing).RowsAffected > 0 {
+		return errors.New("摄像头编码已存在")
+	}
+	return global.GVA_DB.Create(&dao.Camera{
+		DeviceCode: req.DeviceCode, DeviceName: req.DeviceName, ChannelID: req.ChannelID,
+		IPAddress: req.IPAddress, Port: req.Port, Username: req.Username, Password: req.Password,
+		Channel: req.Channel, IsActive: req.IsActive, StreamProtocol: req.StreamProtocol,
+		SourceURL: req.SourceURL, StreamURL: req.StreamURL, SnapshotURL: req.SnapshotURL, Description: req.Description,
+	}).Error
+}
+
+// UpdateCamera 更新摄像头配置;为了避免浏览器回显凭据,空密码不会覆盖数据库已有密码。
+func (s *PassageService) UpdateCamera(req request.CameraUpdate) error {
+	if err := validateCameraConfig(req.CameraCreate); err != nil {
+		return err
+	}
+	var camera dao.Camera
+	if err := global.GVA_DB.First(&camera, req.ID).Error; err != nil {
+		return errors.New("摄像头不存在")
+	}
+	var existing dao.Camera
+	if global.GVA_DB.Where("device_code = ? AND id != ?", req.DeviceCode, req.ID).First(&existing).RowsAffected > 0 {
+		return errors.New("摄像头编码已被其他设备使用")
+	}
+	previousDeviceCode := camera.DeviceCode
+	camera.DeviceCode, camera.DeviceName, camera.ChannelID = req.DeviceCode, req.DeviceName, req.ChannelID
+	camera.IPAddress, camera.Port = req.IPAddress, req.Port
+	if strings.TrimSpace(req.Username) != "" {
+		camera.Username = req.Username
+	}
+	if strings.TrimSpace(req.Password) != "" {
+		camera.Password = req.Password
+	}
+	camera.Channel, camera.IsActive, camera.StreamProtocol = req.Channel, req.IsActive, req.StreamProtocol
+	// 前端编辑时密码会被掩码;此时保留数据库中的完整 RTSP 地址。
+	if !strings.Contains(req.SourceURL, "***") {
+		camera.SourceURL = req.SourceURL
+	}
+	camera.StreamURL, camera.SnapshotURL, camera.Description = req.StreamURL, req.SnapshotURL, req.Description
+	if err := global.GVA_DB.Save(&camera).Error; err != nil {
+		return err
+	}
+	cameraMJPEGHub.stop(previousDeviceCode)
+	if camera.DeviceCode != previousDeviceCode {
+		cameraMJPEGHub.stop(camera.DeviceCode)
+	}
+	return nil
+}
+
+func (s *PassageService) ListCameras(req request.CameraQuery) (int64, []dao.Camera, error) {
+	var total int64
+	var cameras []dao.Camera
+	query := global.GVA_DB.Model(&dao.Camera{}).Preload("BoundChannel")
+	if req.ParkingLotID != 0 {
+		query = query.Joins("JOIN channel ON channel.id = camera.channel_id AND channel.deleted_at IS NULL").Where("channel.parking_lot_id = ?", req.ParkingLotID)
+	}
+	if err := query.Count(&total).Error; err != nil {
+		return 0, nil, err
+	}
+	if err := query.Order("camera.device_name ASC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&cameras).Error; err != nil {
+		return 0, nil, err
+	}
+	// 播放地址可能含凭据,配置列表不向浏览器返回它。
+	for i := range cameras {
+		cameras[i].Status = cameraDisplayStatus(cameras[i])
+		cameras[i].Username = ""
+		cameras[i].Password = ""
+		cameras[i].SourceURL = redactCameraURL(cameras[i].SourceURL)
+	}
+	return total, cameras, nil
+}
+
+func (s *PassageService) DeleteCamera(id uint) error {
+	var camera dao.Camera
+	if err := global.GVA_DB.First(&camera, id).Error; err != nil {
+		return errors.New("摄像头不存在")
+	}
+	result := global.GVA_DB.Delete(&camera)
+	if result.Error != nil {
+		return result.Error
+	}
+	if result.RowsAffected == 0 {
+		return errors.New("摄像头不存在")
+	}
+	cameraMJPEGHub.stop(camera.DeviceCode)
+	return nil
+}
+
+func validateCameraConfig(req request.CameraCreate) error {
+	if global.GVA_DB == nil {
+		return errors.New("数据库未初始化")
+	}
+	if req.StreamProtocol == "system-mjpeg" && strings.TrimSpace(req.SourceURL) == "" {
+		return errors.New("系统转换模式必须填写原始 RTSP/HTTP 地址")
+	}
+	if req.StreamProtocol != "system-mjpeg" && strings.TrimSpace(req.StreamURL) == "" {
+		return errors.New("边缘播放模式必须填写浏览器播放地址")
+	}
+	var channel dao.Channel
+	if err := global.GVA_DB.First(&channel, req.ChannelID).Error; err != nil {
+		return errors.New("绑定通道不存在")
+	}
+	return nil
+}
+
 // ListCameraStreams 返回启用摄像头的浏览器播放配置,并按通道绑定到道闸设备。
 func (s *PassageService) ListCameraStreams() ([]CameraStreamOption, error) {
 	if global.GVA_DB == nil {
@@ -55,10 +167,7 @@ func (s *PassageService) ListCameraStreams() ([]CameraStreamOption, error) {
 			// 切到 edge 模式后,沿用数据库中的 StreamURL(由边缘或外部媒体网关提供)。
 			protocol = "webrtc"
 		}
-		status := camera.Status
-		if status == "" {
-			status = "offline"
-		}
+		status := cameraDisplayStatus(camera)
 		options = append(options, CameraStreamOption{
 			DeviceCode:  camera.DeviceCode,
 			DeviceName:  camera.DeviceName,
@@ -69,12 +178,28 @@ func (s *PassageService) ListCameraStreams() ([]CameraStreamOption, error) {
 			PlayURL:     playURL,
 			SnapshotURL: camera.SnapshotURL,
 			Status:      status,
-			Online:      camera.Status == "online" || camera.Status == "connected",
+			Online:      status == "online" || status == "connected",
 		})
 	}
 	return options, nil
 }
 
+// cameraDisplayStatus returns the observed stream state for system-side
+// conversion. Camera.Status is not updated by FFmpeg, so using it there made
+// an actively playing RTSP stream appear offline indefinitely.
+func cameraDisplayStatus(camera dao.Camera) string {
+	if isSystemMJPEG(camera.StreamProtocol) && strings.ToLower(strings.TrimSpace(global.GVA_CONFIG.Camera.ConversionMode)) != "edge" {
+		if hasRecentCameraStreamFrame(camera.DeviceCode) {
+			return "online"
+		}
+		return "offline"
+	}
+	if camera.Status == "" {
+		return "offline"
+	}
+	return camera.Status
+}
+
 func cameraChannelCode(channel *dao.Channel) string {
 	if channel == nil {
 		return ""

+ 27 - 0
internal/service/parking/camera_stream_session_test.go

@@ -0,0 +1,27 @@
+package parking
+
+import (
+	"testing"
+	"time"
+)
+
+func TestCameraStreamSessionPublishesOneSourceToAllSubscribers(t *testing.T) {
+	hub := newCameraStreamHub()
+	session := newCameraStreamSession(hub, cameraStreamConfig{DeviceCode: "CAM-TEST"})
+	_, first := session.addSubscriber()
+	_, second := session.addSubscriber()
+
+	frame := []byte("--ffmpeg\r\nContent-Type: image/jpeg\r\n\r\nframe")
+	session.publish(frame)
+
+	for _, subscriber := range []<-chan []byte{first, second} {
+		select {
+		case received := <-subscriber:
+			if string(received) != string(frame) {
+				t.Fatalf("published frame = %q, want %q", received, frame)
+			}
+		case <-time.After(time.Second):
+			t.Fatal("subscriber did not receive the shared frame")
+		}
+	}
+}

+ 26 - 0
internal/service/parking/camera_stream_status_test.go

@@ -0,0 +1,26 @@
+package parking
+
+import (
+	"testing"
+	"time"
+)
+
+func TestRecentCameraStreamFrameMarksCameraOnline(t *testing.T) {
+	deviceCode := "camera-stream-status-test"
+	cameraLastFrameAt.Delete(deviceCode)
+	t.Cleanup(func() { cameraLastFrameAt.Delete(deviceCode) })
+
+	if hasRecentCameraStreamFrame(deviceCode) {
+		t.Fatal("camera without video output must be offline")
+	}
+
+	markCameraStreamFrame(deviceCode)
+	if !hasRecentCameraStreamFrame(deviceCode) {
+		t.Fatal("camera with recent video output must be online")
+	}
+
+	cameraLastFrameAt.Store(deviceCode, time.Now().Add(-cameraStreamOnlineTTL-time.Millisecond))
+	if hasRecentCameraStreamFrame(deviceCode) {
+		t.Fatal("camera with expired video output must be offline")
+	}
+}