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 }}
-
+
{{ .LoginError }}
+ {{ 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 }}
+
+
+ {{ 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 }}