From 7ca663d994a430cbe04ff90bfac020fe2bc8052d Mon Sep 17 00:00:00 2001 From: niklzz <16826549+niklzz@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:58:44 +0300 Subject: [PATCH] feat(home): show neighboring entrance cameras and per-camera HA config Dom.ru now exposes cameras of neighboring entrances via /rest/v1/places/{placeId}/screen-sections (viewable with a Pro subscription). The home page loads that endpoint per place and renders one card per camera, with its own Home Assistant snippet. Cameras are matched by externalCameraId / forpostGroupId instead of the array index, which broke with more than one place or camera. Door permissions are taken from /rest/v1/places/{placeId}/accesscontrols: after a subscription the embedded list in subscriberplaces advertises allowOpen for neighboring doors that cannot actually be opened. Neighbor cards never get an "Open" button. Extra endpoints failing (or 404 on operators without screen-sections) degrade gracefully: the basic camera list stays visible. --- README.md | 12 ++ cmd/controllers/common.go | 7 +- cmd/controllers/home.go | 42 ++++- cmd/controllers/home_cameras.go | 138 ++++++++++++++++ cmd/controllers/home_cameras_test.go | 238 +++++++++++++++++++++++++++ cmd/models/home.go | 22 ++- pkg/domru/apiWrapper.go | 20 +++ pkg/domru/models/cameras.go | 22 +++ pkg/domru/models/places.go | 4 + templates/home.html.tmpl | 134 +++++++-------- 10 files changed, 558 insertions(+), 81 deletions(-) create mode 100644 cmd/controllers/home_cameras.go create mode 100644 cmd/controllers/home_cameras_test.go diff --git a/README.md b/README.md index 9aac2e7..504cc53 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,16 @@ This is a simple reverse proxy which adds authentication token to requests to do Also provides a simple web interface to view camera snapshots and open doors +The home page lists your own intercoms and, when the operator exposes +`/rest/v1/places/{placeId}/screen-sections`, cameras of neighboring entrances. +Neighboring cameras are viewable only with an active Pro subscription; otherwise +their cards say so. Cameras are matched by camera/group IDs rather than list +order, and the "Open door" button is shown only for your own access controls +(taken from `/rest/v1/places/{placeId}/accesscontrols` with `allowOpen`). If the +extra endpoints fail or return 404, the basic camera list is still rendered. +Every card comes with its own Home Assistant snippet; merge them under a single +`camera:` / `rest_command:` section. + ## Run in Docker Find available docker images here: https://github.com/moleus/domru/pkgs/container/domru Please, don't use `latest` tag, because new update can break your setup @@ -97,6 +107,8 @@ All other requests are forwarded to Domru API. A few of them: | Endpoint | Method | Description | |-----------------------------------------------------------------------------|--------|--------------------| | `/rest/v1/forpost/cameras` | GET | Get cameras list | +| `/rest/v1/places/{placeId}/screen-sections` | GET | Additional camera sections and subscription access | +| `/rest/v1/places/{placeId}/accesscontrols/{accessControlId}/snapshots` | GET | Camera snapshot, including authorized neighboring entrances | | `/rest/v1/places/{placeId}/accesscontrols/{accessControlId}/actions` | POST | Open door | | `/rest/v1/subscribers/profiles/finances` | GET | Get finances | | `/rest/v1/subscribers/profiles` | GET | Get profile info | diff --git a/cmd/controllers/common.go b/cmd/controllers/common.go index c51d6dd..8fe04b1 100644 --- a/cmd/controllers/common.go +++ b/cmd/controllers/common.go @@ -9,7 +9,6 @@ import ( "github.com/moleus/domru/pkg/auth" "github.com/moleus/domru/pkg/domru" - "github.com/moleus/domru/pkg/domru/constants" "github.com/moleus/domru/pkg/domru/models" "github.com/moleus/domru/pkg/homeassistant" ) @@ -57,11 +56,7 @@ func (h *Handler) renderTemplate(w http.ResponseWriter, templateName string, dat } func getTemplateFunctions() template.FuncMap { - return template.FuncMap{ - "getSnapshotUrl": constants.GetSnapshotUrl, - "getOpenDoorUrl": constants.GetOpenDoorUrl, - "getCameraStreamUrl": constants.GetCameraStreamUrl, - } + return template.FuncMap{} } func (h *Handler) determineBaseURL(r *http.Request) string { var scheme string diff --git a/cmd/controllers/home.go b/cmd/controllers/home.go index 7a00265..577cee9 100644 --- a/cmd/controllers/home.go +++ b/cmd/controllers/home.go @@ -2,11 +2,14 @@ package controllers import ( errors2 "errors" + "fmt" "net/http" "strings" "github.com/moleus/domru/cmd/models" "github.com/moleus/domru/pkg/authorizedhttp" + "github.com/moleus/domru/pkg/domru/helpers" + domrumodels "github.com/moleus/domru/pkg/domru/models" ) func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) { @@ -52,10 +55,43 @@ func (h *Handler) prepareHomePageData(r *http.Request) (models.HomePageData, err } } - errorsMessage := strings.Join(errors, "\n") - data.BaseURL = h.determineBaseURL(r) - data.LoginError = errorsMessage + sections := make(map[int]domrumodels.ScreenSectionsResponse) + controlsByPlace := make(map[int][]domrumodels.AccessControl) + for i, item := range data.Places.Data { + placeID := item.Place.ID + controls, exists := controlsByPlace[placeID] + if !exists { + result, err := h.domruAPI.RequestAccessControls(placeID) + controls = result.Data + if err != nil { + // The legacy embedded list can incorrectly advertise door access + // for paid neighboring cameras. Keep video metadata only on failure. + controls = append([]domrumodels.AccessControl(nil), item.Place.AccessControls...) + for j := range controls { + controls[j].AllowOpen = false + } + errors = append(errors, fmt.Sprintf("Не удалось проверить доступ к домофонам адреса %d: %v", placeID, err)) + } + controlsByPlace[placeID] = controls + } + data.Places.Data[i].Place.AccessControls = controls + if _, exists := sections[placeID]; exists { + continue + } + result, err := h.domruAPI.RequestScreenSections(placeID) + sections[placeID] = result + if err != nil { + // Older operators may not expose this optional endpoint. + var upstreamErr *helpers.UpstreamError + if errors2.As(err, &upstreamErr) && upstreamErr.StatusCode == http.StatusNotFound { + continue + } + errors = append(errors, fmt.Sprintf("Не удалось загрузить дополнительные камеры адреса %d: %v", placeID, err)) + } + } + data.CameraCards = buildCameraCards(data.BaseURL, data.Places, data.Cameras, sections) + data.LoginError = strings.Join(errors, "\n") return data, nil } diff --git a/cmd/controllers/home_cameras.go b/cmd/controllers/home_cameras.go new file mode 100644 index 0000000..45f75fb --- /dev/null +++ b/cmd/controllers/home_cameras.go @@ -0,0 +1,138 @@ +package controllers + +import ( + "fmt" + "sort" + "strconv" + + "github.com/moleus/domru/cmd/models" + "github.com/moleus/domru/pkg/domru/constants" + domrumodels "github.com/moleus/domru/pkg/domru/models" +) + +func cameraID(value interface{}) int { + id, err := strconv.Atoi(fmt.Sprint(value)) + if err != nil || id <= 0 { + return 0 + } + return id +} + +func accessControlCameraID(ac domrumodels.AccessControl, cameras []domrumodels.Camera) int { + if id := cameraID(ac.ExternalCameraId); id != 0 { + return id + } + for _, camera := range cameras { + for _, group := range camera.ParentGroups { + if strconv.Itoa(group.ID) == ac.ForpostGroupId { + return camera.ID + } + } + } + return 0 +} + +func setCameraMedia(card *models.CameraCard, baseURL, snapshotURL string, allowVideo, allowSnapshot bool) { + if allowVideo && card.ID > 0 { + card.StreamURL = constants.GetCameraStreamUrl(baseURL, card.ID) + } + if allowSnapshot { + card.SnapshotURL = snapshotURL + } + switch { + case card.StreamURL != "": + card.Status = "Доступна" + case card.SnapshotURL != "": + card.Status = "Доступен снимок; видеопоток недоступен" + default: + card.Status = "Просмотр недоступен" + } +} + +func buildCameraCards(baseURL string, places domrumodels.PlacesResponse, cameras domrumodels.CamerasResponse, sections map[int]domrumodels.ScreenSectionsResponse) []models.CameraCard { + var cards []models.CameraCard + seenCameras := make(map[int]bool) + seenControls := make(map[[2]int]bool) + sectionControls := make(map[[2]int]bool) + for placeID, response := range sections { + for _, section := range response.Sections { + if section.Type == "ACCESS_CONTROL_CAMERA" { + for _, camera := range section.Entities { + sectionControls[[2]int{placeID, camera.AccessControlID}] = true + } + } + } + } + + for _, item := range places.Data { + place := item.Place + for _, ac := range place.AccessControls { + key := [2]int{place.ID, ac.ID} + if seenControls[key] || sectionControls[key] { + continue + } + seenControls[key] = true + id := accessControlCameraID(ac, cameras.Data) + card := models.CameraCard{ + ID: id, Name: ac.Name, Section: "Мои домофоны", + ConfigName: fmt.Sprintf("domofon_%d_%d", place.ID, ac.ID), + } + setCameraMedia(&card, baseURL, constants.GetSnapshotUrl(baseURL, place.ID, ac.ID), + ac.AllowVideo, ac.AllowSlideshow || ac.PreviewAvailable) + if ac.AllowOpen { + card.OpenDoorURL = constants.GetOpenDoorUrl(baseURL, place.ID, ac.ID) + } + cards = append(cards, card) + seenCameras[id] = true + } + } + + for _, item := range places.Data { + placeID := item.Place.ID + placeSections := append([]domrumodels.ScreenSection(nil), sections[placeID].Sections...) + sort.SliceStable(placeSections, func(i, j int) bool { return placeSections[i].Order < placeSections[j].Order }) + for _, section := range placeSections { + if section.Type != "ACCESS_CONTROL_CAMERA" { + continue + } + for _, camera := range section.Entities { + key := [2]int{placeID, camera.AccessControlID} + if camera.AccessControlID <= 0 || seenControls[key] { + continue + } + seenControls[key] = true + id := cameraID(camera.ExternalCameraID) + if id > 0 && seenCameras[id] { + continue + } + card := models.CameraCard{ + ID: id, Name: camera.Name, Section: "Соседний подъезд", + ConfigName: fmt.Sprintf("domofon_%d_%d", placeID, camera.AccessControlID), + Status: "Требуется подписка Pro", + } + if camera.ServiceActivated { + snapshotURL := fmt.Sprintf("%s/rest/v1/places/%d/accesscontrols/%d/snapshots?width=320&height=180", baseURL, placeID, camera.AccessControlID) + setCameraMedia(&card, baseURL, snapshotURL, camera.AllowVideo, camera.AllowSlideshow || camera.PreviewAvailable) + } + cards = append(cards, card) + seenCameras[id] = true + } + } + } + + // Keep standalone account cameras that are not associated with a door. + for _, camera := range cameras.Data { + if camera.ID <= 0 || seenCameras[camera.ID] { + continue + } + card := models.CameraCard{ + ID: camera.ID, Name: camera.Name, Section: "Другие камеры", + ConfigName: fmt.Sprintf("domru_camera_%d", camera.ID), + } + snapshotURL := fmt.Sprintf("%s/rest/v1/forpost/cameras/%d/snapshots?width=320&height=180", baseURL, camera.ID) + setCameraMedia(&card, baseURL, snapshotURL, camera.IsActive == 1, camera.IsActive == 1) + cards = append(cards, card) + seenCameras[camera.ID] = true + } + return cards +} diff --git a/cmd/controllers/home_cameras_test.go b/cmd/controllers/home_cameras_test.go new file mode 100644 index 0000000..7a41552 --- /dev/null +++ b/cmd/controllers/home_cameras_test.go @@ -0,0 +1,238 @@ +package controllers + +import ( + "bytes" + "errors" + "html/template" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + pagemodels "github.com/moleus/domru/cmd/models" + "github.com/moleus/domru/pkg/domru" + domrumodels "github.com/moleus/domru/pkg/domru/models" + "github.com/stretchr/testify/require" +) + +const cameraTestBase = "http://localhost:8080" + +func TestCameraCardsMatchAcrossPlacesAndKeepStandaloneCameras(t *testing.T) { + places := domrumodels.PlacesResponse{Data: []domrumodels.Data{ + {Place: domrumodels.Place{ID: 10, AccessControls: []domrumodels.AccessControl{ + {ID: 11, Name: "First door", ForpostGroupId: "101", AllowOpen: true, AllowVideo: true, PreviewAvailable: true}, + {ID: 12, Name: "Unmatched door", AllowVideo: true}, + }}}, + {Place: domrumodels.Place{ID: 20, AccessControls: []domrumodels.AccessControl{ + {ID: 21, Name: "Second door", ExternalCameraId: "202", AllowVideo: true, AllowSlideshow: true}, + }}}, + }} + cameras := domrumodels.CamerasResponse{Data: []domrumodels.Camera{ + {ID: 202, IsActive: 1}, + {ID: 303, Name: "Standalone", IsActive: 1}, + {ID: 201, IsActive: 1, ParentGroups: []domrumodels.ParentGroup{{ID: 101}}}, + }} + cards := buildCameraCards(cameraTestBase, places, cameras, nil) + require.Len(t, cards, 4) + require.Equal(t, 201, cards[0].ID) + require.Equal(t, cameraTestBase+"/stream/201", cards[0].StreamURL) + require.Equal(t, cameraTestBase+"/rest/v1/places/10/accesscontrols/11/actions", cards[0].OpenDoorURL) + require.Zero(t, cards[1].ID, "an unmatched door must not borrow a camera by array position") + require.Empty(t, cards[1].StreamURL) + require.Equal(t, 202, cards[2].ID) + require.Empty(t, cards[2].OpenDoorURL) + require.Equal(t, 303, cards[3].ID) + require.Contains(t, cards[3].SnapshotURL, "/forpost/cameras/303/snapshots") + require.Empty(t, cards[3].OpenDoorURL) +} + +func TestNeighborCameraEntitlements(t *testing.T) { + for _, tc := range []struct { + name string + activated bool + video bool + preview bool + id string + wantStream bool + wantSnapshot bool + }{ + {name: "unpaid with advertised capabilities", video: true, preview: true, id: "301"}, + {name: "unpaid null ID", video: true, preview: true}, + {name: "paid", activated: true, video: true, preview: true, id: "301", wantStream: true, wantSnapshot: true}, + {name: "paid without video permission", activated: true, preview: true, id: "301", wantSnapshot: true}, + {name: "paid missing camera ID", activated: true, video: true, preview: true, wantSnapshot: true}, + {name: "paid invalid camera ID", activated: true, video: true, id: "../actions"}, + {name: "paid without preview", activated: true, video: true, id: "301", wantStream: true}, + } { + t.Run(tc.name, func(t *testing.T) { + places := domrumodels.PlacesResponse{Data: []domrumodels.Data{{Place: domrumodels.Place{ID: 10}}}} + sections := map[int]domrumodels.ScreenSectionsResponse{10: {Sections: []domrumodels.ScreenSection{ + {Type: "ACCESS_CONTROL_CAMERA", Title: "Соседний подъезд", Entities: []domrumodels.AccessControlCamera{ + {AccessControlID: 31, Name: "Neighbor", ServiceActivated: tc.activated, AllowVideo: tc.video, PreviewAvailable: tc.preview, ExternalCameraID: tc.id}, + }}, + }}} + cards := buildCameraCards(cameraTestBase, places, domrumodels.CamerasResponse{}, sections) + require.Len(t, cards, 1) + require.Equal(t, tc.wantStream, cards[0].StreamURL != "") + require.Equal(t, tc.wantSnapshot, cards[0].SnapshotURL != "") + require.Empty(t, cards[0].OpenDoorURL, "a neighboring camera never grants door access") + if !tc.activated { + require.Equal(t, "Требуется подписка Pro", cards[0].Status) + } + if tc.wantStream { + require.Equal(t, cameraTestBase+"/stream/301", cards[0].StreamURL) + } + if tc.wantSnapshot { + require.Contains(t, cards[0].SnapshotURL, "/places/10/accesscontrols/31/snapshots?") + } + }) + } +} + +func TestCameraCardsDeduplicateSourcesAndIgnoreOtherSections(t *testing.T) { + place := domrumodels.Data{Place: domrumodels.Place{ID: 10, AccessControls: []domrumodels.AccessControl{ + {ID: 11, ExternalCameraId: float64(201), AllowVideo: true}, + }}} + sections := map[int]domrumodels.ScreenSectionsResponse{10: {Sections: []domrumodels.ScreenSection{ + {Type: "OTHER", Entities: []domrumodels.AccessControlCamera{{AccessControlID: 99}}}, + {Type: "ACCESS_CONTROL_CAMERA", Entities: []domrumodels.AccessControlCamera{ + {AccessControlID: 11, ExternalCameraID: "201", ServiceActivated: true, AllowVideo: true}, + {AccessControlID: 31, ExternalCameraID: "301", ServiceActivated: true, AllowVideo: true}, + {AccessControlID: 31, ExternalCameraID: "301", ServiceActivated: true, AllowVideo: true}, + }}, + }}} + cards := buildCameraCards(cameraTestBase, domrumodels.PlacesResponse{Data: []domrumodels.Data{place, place}}, + domrumodels.CamerasResponse{Data: []domrumodels.Camera{{ID: 201}, {ID: 301}}}, sections) + require.Len(t, cards, 2) + require.Equal(t, 201, cards[0].ID) + require.Equal(t, 301, cards[1].ID) +} + +type cameraHTTPClient func(*http.Request) (*http.Response, error) + +func (f cameraHTTPClient) Do(r *http.Request) (*http.Response, error) { return f(r) } + +func TestHomePageLoadsSectionsAndPreservesCamerasOnFailure(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusNotFound, http.StatusForbidden, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + var sectionCalls int + client := cameraHTTPClient(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + code := http.StatusOK + var body string + switch r.URL.Path { + case "/rest/v1/forpost/cameras": + body = `{"data":[{"ID":201,"IsActive":1,"ParentGroups":[{"ID":101}]}]}` + case "/rest/v1/subscriberplaces": + body = `{"data":[{"place":{"id":10,"accessControls":[{"id":11,"name":"Own","forpostGroupId":"101","allowOpen":true,"allowVideo":true,"previewAvailable":true}]}},{"place":{"id":20}}]}` + case "/rest/v1/places/10/accesscontrols": + body = `{"data":[{"id":11,"name":"Own","externalCameraId":"201","allowOpen":true,"allowVideo":true,"previewAvailable":true}]}` + case "/rest/v1/places/20/accesscontrols": + body = `{"data":[]}` + case "/rest/v1/subscribers/profiles": + body = `{}` + case "/rest/v1/places/10/screen-sections", "/rest/v1/places/20/screen-sections": + sectionCalls++ + code = status + body = `{"errorCode":"UNAVAILABLE"}` + if status == http.StatusOK { + body = `{"sections":[{"type":"ACCESS_CONTROL_CAMERA","title":"Соседний подъезд","entities":[{"accessControlId":31,"name":"Neighbor","serviceActivated":true,"allowVideo":true,"previewAvailable":true,"externalCameraId":"301"},{"accessControlId":32,"name":"Locked","serviceActivated":false,"allowVideo":true,"externalCameraId":null}]}]}` + } + default: + return nil, errors.New("unexpected endpoint: " + r.URL.Path) + } + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }) + handler := &Handler{domruAPI: domru.NewDomruAPI(client)} + data, err := handler.prepareHomePageData(httptest.NewRequest(http.MethodGet, cameraTestBase+"/pages/home.html", nil)) + require.NoError(t, err) + require.Equal(t, 2, sectionCalls, "load sections for every account place") + require.NotEmpty(t, data.CameraCards) + require.Equal(t, 201, data.CameraCards[0].ID) + require.NotEmpty(t, data.CameraCards[0].StreamURL) + if status == http.StatusOK { + require.Greater(t, len(data.CameraCards), 1) + require.Equal(t, 301, data.CameraCards[1].ID) + } + if status == http.StatusOK || status == http.StatusNotFound { + require.Empty(t, data.LoginError) + } else { + require.NotEmpty(t, data.LoginError) + } + html := renderCameraTestPage(t, data) + require.Contains(t, html, "/stream/201", "partial errors must not hide working cameras") + }) + } +} + +func renderCameraTestPage(t *testing.T, data pagemodels.HomePageData) string { + t.Helper() + source, err := os.ReadFile("../../templates/home.html.tmpl") + require.NoError(t, err) + tmpl, err := template.New("home").Funcs(getTemplateFunctions()).Parse(string(source)) + require.NoError(t, err) + var buffer bytes.Buffer + require.NoError(t, tmpl.Execute(&buffer, data)) + return buffer.String() +} + +func TestHomeTemplateMediaPermissionsAndEscaping(t *testing.T) { + locked := pagemodels.CameraCard{Name: "", Status: "Требуется подписка Pro"} + html := renderCameraTestPage(t, pagemodels.HomePageData{CameraCards: []pagemodels.CameraCard{locked}}) + require.Contains(t, html, "Требуется подписка Pro") + require.NotContains(t, html, "") + require.NotContains(t, html, "") + require.Contains(t, html, "<script>") + + html = renderCameraTestPage(t, pagemodels.HomePageData{CameraCards: []pagemodels.CameraCard{ + {ID: 301, Name: "Neighbor", ConfigName: "domofon_10_31", SnapshotURL: cameraTestBase + "/snapshot", StreamURL: cameraTestBase + "/stream/301"}, + }}) + require.Contains(t, html, "
+

Камеры аккаунта

{{ if .LoginError }} -
- {{ .LoginError }} -
- {{ else }} -
-
- {{ if .Phone }} -
-
Номер телефона:
-
+{{ .Phone }}
-
- {{ end }} - {{ range $_, $placeEl := .Places.Data }} - {{ range $index, $ac := $placeEl.Place.AccessControls }} - {{$snapshotUrl := getSnapshotUrl $.BaseURL $placeEl.Place.ID $ac.ID }} - {{$streamUrl := "Camera for this index not found ;("}} - {{$cameraId := ""}} - {{ if lt $index (len $.Cameras.Data) }} - {{ with (index $.Cameras.Data $index) }} - {{$streamUrl = getCameraStreamUrl $.BaseURL .ID }} - {{$cameraId = printf "№ %d" .ID }} - {{ end }} - {{ end }} - {{$openDoorUrl := getOpenDoorUrl $.BaseURL $placeEl.Place.ID $ac.ID }} - -
-
Камера:
-
{{ $cameraId }}
-
-
-
Адрес:
-
- {{ $ac.Name }} - + + {{ end }} + {{ if .Phone }}

Номер телефона: +{{ .Phone }}

{{ end }} + {{ range .CameraCards }} +
+

{{ .Section }} — {{ .Name }}

+
+
+
+
Камера:
+
{{ if .ID }}№ {{ .ID }}{{ else }}Идентификатор пока недоступен{{ end }}
-
-
-
Видеопоток:
-
- {{ $streamUrl }} +
+
Адрес / название:
+
+ {{ .Name }} + {{ if .OpenDoorURL }}{{ end }} +
-
-
-
-
- Камера +
+
Доступ:
+
{{ .Status }}
+ {{ if .StreamURL }} +
+
Видеопоток:
+ +
+ {{ end }} + {{ if .SnapshotURL }} +
+
Снимок:
+ +
+
+
+
{{ .Name }}
+
+ {{ end }}
-
-
-
-
-# Home Assistant configuration:
-rest_command:
-    domru_open_door:
-        url: {{ $openDoorUrl }}
+        
+ {{ if or .StreamURL .SnapshotURL .OpenDoorURL }} +
# Home Assistant configuration:
+{{ if .OpenDoorURL }}rest_command:
+    {{ .ConfigName }}_open_door:
+        url: {{ .OpenDoorURL }}
         method: post
         headers:
             accept: "application/json"
-        content_type:  'application/json; charset=utf-8'
+        content_type: 'application/json; charset=utf-8'
         payload: '{"name":"accessControlOpen"}'
-
+{{ end }}{{ if or .StreamURL .SnapshotURL }}
 camera:
     - platform: generic
-        name: domofon
-        still_image_url: {{ $snapshotUrl }}
-        stream_source: {{ $streamUrl }}
-                        
-
-
- {{ end }} - {{ end }} -
-
+ name: {{ .ConfigName }} +{{ if .SnapshotURL }} still_image_url: {{ .SnapshotURL }} +{{ end }}{{ if .StreamURL }} stream_source: {{ .StreamURL }} +{{ end }}{{ end }} + {{ end }} +
+ {{ else }} +

Камеры не найдены.

{{ end }}