Rewrite HomeKit pairing API

This commit is contained in:
Alexey Khit
2023-09-01 22:48:06 +03:00
parent 0621b82aff
commit 9f404d965f
11 changed files with 367 additions and 275 deletions
+18 -2
View File
@@ -50,7 +50,8 @@ func Init() {
HandleFunc("api/exit", exitHandler)
// ensure we can listen without errors
listener, err := net.Listen("tcp", cfg.Mod.Listen)
var err error
ln, err = net.Listen("tcp", cfg.Mod.Listen)
if err != nil {
log.Fatal().Err(err).Msg("[api] listen")
return
@@ -75,7 +76,7 @@ func Init() {
go func() {
s := http.Server{}
s.Handler = Handler
if err = s.Serve(listener); err != nil {
if err = s.Serve(ln); err != nil {
log.Fatal().Err(err).Msg("[api] serve")
}
}()
@@ -111,6 +112,13 @@ func Init() {
}
}
func Port() int {
if ln == nil {
return 0
}
return ln.Addr().(*net.TCPAddr).Port
}
const (
MimeJSON = "application/json"
MimeText = "text/plain"
@@ -192,6 +200,7 @@ func middlewareCORS(next http.Handler) http.Handler {
})
}
var ln net.Listener
var mu sync.Mutex
func apiHandler(w http.ResponseWriter, r *http.Request) {
@@ -216,6 +225,7 @@ func exitHandler(w http.ResponseWriter, r *http.Request) {
type Source struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Info string `json:"info,omitempty"`
URL string `json:"url,omitempty"`
Location string `json:"location,omitempty"`
}
@@ -233,3 +243,9 @@ func ResponseSources(w http.ResponseWriter, sources []*Source) {
}
ResponseJSON(w, response)
}
func Error(w http.ResponseWriter, err error) {
log.Error().Err(err).Caller(1).Send()
http.Error(w, err.Error(), http.StatusInsufficientStorage)
}
+20 -1
View File
@@ -1,6 +1,7 @@
package app
import (
"errors"
"flag"
"fmt"
"io"
@@ -11,9 +12,9 @@ import (
"time"
"github.com/AlexxIT/go2rtc/pkg/shell"
"github.com/AlexxIT/go2rtc/pkg/yaml"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
)
var Version = "1.6.2"
@@ -81,6 +82,8 @@ func Init() {
modules = cfg.Mod
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
migrateStore()
}
func NewLogger(format string, level string) zerolog.Logger {
@@ -123,6 +126,22 @@ func GetLogger(module string) zerolog.Logger {
return log.Logger
}
func PatchConfig(key string, value any, path ...string) error {
if ConfigPath == "" {
return errors.New("config file disabled")
}
// empty config is OK
b, _ := os.ReadFile(ConfigPath)
b, err := yaml.Patch(b, key, value, path...)
if err != nil {
return err
}
return os.WriteFile(ConfigPath, b, 0644)
}
// internal
type Config []string
+35
View File
@@ -0,0 +1,35 @@
package app
import (
"encoding/json"
"os"
"github.com/rs/zerolog/log"
)
func migrateStore() {
const name = "go2rtc.json"
data, _ := os.ReadFile(name)
if data == nil {
return
}
var store struct {
Streams map[string]string `json:"streams"`
}
if err := json.Unmarshal(data, &store); err != nil {
log.Warn().Err(err).Caller().Send()
return
}
for id, url := range store.Streams {
if err := PatchConfig(id, url, "streams"); err != nil {
log.Warn().Err(err).Caller().Send()
return
}
}
_ = os.Remove(name)
}
-61
View File
@@ -1,61 +0,0 @@
package store
import (
"encoding/json"
"github.com/rs/zerolog/log"
"os"
)
const name = "go2rtc.json"
var store map[string]any
func load() {
data, _ := os.ReadFile(name)
if data != nil {
if err := json.Unmarshal(data, &store); err != nil {
// TODO: log
log.Warn().Err(err).Msg("[app] read storage")
}
}
if store == nil {
store = make(map[string]any)
}
}
func save() error {
data, err := json.Marshal(store)
if err != nil {
return err
}
return os.WriteFile(name, data, 0644)
}
func GetRaw(key string) any {
if store == nil {
load()
}
return store[key]
}
func GetDict(key string) map[string]any {
raw := GetRaw(key)
if raw != nil {
return raw.(map[string]any)
}
return make(map[string]any)
}
func Set(key string, v any) error {
if store == nil {
load()
}
store[key] = v
return save()
}
+98 -93
View File
@@ -1,12 +1,14 @@
package homekit
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app/store"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/mdns"
@@ -15,119 +17,122 @@ import (
func apiHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
items := make([]any, 0)
for name, src := range store.GetDict("streams") {
if src := src.(string); strings.HasPrefix(src, "homekit") {
u, err := url.Parse(src)
if err != nil {
continue
}
device := Device{
Name: name,
Addr: u.Host,
Paired: true,
}
items = append(items, device)
}
}
err := mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
log.Trace().Msgf("[homekit] mdns=%s", entry)
if entry.Complete() {
device := Device{
Name: entry.Name,
Addr: entry.Addr(),
ID: entry.Info[hap.TXTDeviceID],
Model: entry.Info[hap.TXTModel],
Paired: entry.Info[hap.TXTStatusFlags] == "0",
}
items = append(items, device)
}
return false
})
sources, err := discovery()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
api.Error(w, err)
return
}
api.ResponseJSON(w, items)
urls := findHomeKitURLs()
for id, u := range urls {
deviceID := u.Query().Get("device_id")
for _, source := range sources {
if strings.Contains(source.URL, deviceID) {
source.Location = id
break
}
}
}
for _, source := range sources {
if source.Location == "" {
source.Location = " "
}
}
api.ResponseSources(w, sources)
case "POST":
// TODO: post params...
if err := r.ParseMultipartForm(1024); err != nil {
api.Error(w, err)
return
}
id := r.URL.Query().Get("id")
pin := r.URL.Query().Get("pin")
name := r.URL.Query().Get("name")
if err := hkPair(id, pin, name); err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil {
api.Error(w, err)
}
case "DELETE":
src := r.URL.Query().Get("src")
if err := hkDelete(src); err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
if err := r.ParseMultipartForm(1024); err != nil {
api.Error(w, err)
return
}
if err := apiUnpair(r.Form.Get("id")); err != nil {
api.Error(w, err)
}
}
}
func hkPair(deviceID, pin, name string) (err error) {
var conn *hap.Client
func discovery() ([]*api.Source, error) {
var sources []*api.Source
if conn, err = hap.Pair(deviceID, pin); err != nil {
return
// 1. Get streams from Discovery
err := mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
log.Trace().Msgf("[homekit] mdns=%s", entry)
if entry.Complete() && entry.Info[hap.TXTCategory] == hap.CategoryCamera {
source := &api.Source{
Name: entry.Name,
Info: entry.Info[hap.TXTModel],
URL: fmt.Sprintf(
"homekit://%s:%d?device_id=%s&feature=%s&status=%s",
entry.IP, entry.Port, entry.Info[hap.TXTDeviceID],
entry.Info[hap.TXTFeatureFlags], entry.Info[hap.TXTStatusFlags],
),
}
sources = append(sources, source)
}
return false
})
if err != nil {
return nil, err
}
streams.New(name, conn.URL())
dict := store.GetDict("streams")
dict[name] = conn.URL()
return store.Set("streams", dict)
return sources, nil
}
func hkDelete(name string) (err error) {
dict := store.GetDict("streams")
for key, rawURL := range dict {
if key != name {
continue
}
var conn *hap.Client
if conn, err = hap.NewClient(rawURL.(string)); err != nil {
return
}
if err = conn.Dial(); err != nil {
return
}
if err = conn.ListPairings(); err != nil {
return
}
if err = conn.DeletePairing(conn.ClientID); err != nil {
log.Error().Err(err).Caller().Send()
}
delete(dict, name)
return store.Set("streams", dict)
func apiPair(id, url string) error {
conn, err := hap.Pair(url)
if err != nil {
return err
}
return nil
streams.New(id, conn.URL())
return app.PatchConfig(id, conn.URL(), "streams")
}
type Device struct {
ID string `json:"id"`
Name string `json:"name"`
Addr string `json:"addr"`
Model string `json:"model"`
Paired bool `json:"paired"`
//Type string `json:"type"`
func apiUnpair(id string) error {
stream := streams.Get(id)
if stream == nil {
return errors.New(api.StreamNotFound)
}
rawURL := findHomeKitURL(stream)
if rawURL == "" {
return errors.New("not homekit source")
}
if err := hap.Unpair(rawURL); err != nil {
return err
}
streams.Delete(id)
return app.PatchConfig(id, nil, "streams")
}
func findHomeKitURLs() map[string]*url.URL {
urls := map[string]*url.URL{}
for id, stream := range streams.Streams() {
if rawURL := findHomeKitURL(stream); rawURL != "" {
if u, err := url.Parse(rawURL); err == nil {
urls[id] = u
}
}
}
return urls
}
+23
View File
@@ -1,6 +1,8 @@
package homekit
import (
"strings"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/srtp"
@@ -23,3 +25,24 @@ var log zerolog.Logger
func streamHandler(url string) (core.Producer, error) {
return homekit.Dial(url, srtp.Server)
}
func findHomeKitURL(stream *streams.Stream) string {
sources := stream.Sources()
if len(sources) == 0 {
return ""
}
url := sources[0]
if strings.HasPrefix(url, "homekit") {
return url
}
if strings.HasPrefix(url, "hass") {
location, _ := streams.Location(url)
if strings.HasPrefix(location, "homekit") {
return url
}
}
return ""
}
+7
View File
@@ -38,6 +38,13 @@ func NewStream(source any) *Stream {
}
}
func (s *Stream) Sources() (sources []string) {
for _, prod := range s.producers {
sources = append(sources, prod.url)
}
return
}
func (s *Stream) SetSource(source string) {
for _, prod := range s.producers {
prod.SetSource(source)
+17 -5
View File
@@ -8,7 +8,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/app/store"
"github.com/rs/zerolog"
)
@@ -25,10 +24,6 @@ func Init() {
streams[name] = NewStream(item)
}
for name, item := range store.GetDict("streams") {
streams[name] = NewStream(item)
}
api.HandleFunc("api/streams", streamsHandler)
}
@@ -118,6 +113,14 @@ func GetAll() (names []string) {
return
}
func Streams() map[string]*Stream {
return streams
}
func Delete(id string) {
delete(streams, id)
}
func streamsHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
src := query.Get("src")
@@ -141,6 +144,11 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) {
if New(name, src) == nil {
http.Error(w, "", http.StatusBadRequest)
return
}
if err := app.PatchConfig(name, src, "streams"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
case "PATCH":
@@ -173,6 +181,10 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) {
case "DELETE":
delete(streams, src)
if err := app.PatchConfig(src, nil, "streams"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
}
+54 -23
View File
@@ -5,43 +5,74 @@ import (
"crypto/sha512"
"errors"
"net"
"net/url"
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
"github.com/AlexxIT/go2rtc/pkg/mdns"
"github.com/tadglines/go-pkgs/crypto/srp"
)
func Pair(deviceID, pin string) (*Client, error) {
var addr string
var mfi bool
_ = mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
if entry.Complete() && entry.Info[TXTDeviceID] == deviceID {
addr = entry.Addr()
mfi = entry.Info[TXTFeatureFlags] == "1"
return true
}
return false
})
if addr == "" {
return nil, errors.New("hap: mdns.Discovery")
// Pair homekit
func Pair(rawURL string) (*Client, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
query := u.Query()
c := &Client{
DeviceAddress: addr,
DeviceID: deviceID,
ClientID: GenerateUUID(),
ClientPrivate: GenerateKey(),
DeviceAddress: u.Host,
DeviceID: query.Get("device_id"),
ClientID: query.Get("client_id"),
ClientPrivate: DecodeKey(query.Get("client_private")),
}
return c, c.Pair(mfi, pin)
if c.ClientID == "" {
c.ClientID = GenerateUUID()
}
if c.ClientPrivate == nil {
c.ClientPrivate = GenerateKey()
}
if err = c.Pair(query.Get("feature"), query.Get("pin")); err != nil {
return nil, err
}
return c, nil
}
func (c *Client) Pair(mfi bool, pin string) (err error) {
func Unpair(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return err
}
query := u.Query()
conn := &Client{
DeviceAddress: u.Host,
DeviceID: query.Get("device_id"),
DevicePublic: DecodeKey(query.Get("device_public")),
ClientID: query.Get("client_id"),
ClientPrivate: DecodeKey(query.Get("client_private")),
}
if err = conn.Dial(); err != nil {
return err
}
defer conn.Close()
if err = conn.ListPairings(); err != nil {
return err
}
return conn.DeletePairing(conn.ClientID)
}
func (c *Client) Pair(feature, pin string) (err error) {
if pin, err = SanitizePin(pin); err != nil {
return err
}
@@ -61,7 +92,7 @@ func (c *Client) Pair(mfi bool, pin string) (err error) {
Method: MethodPair,
State: StateM1,
}
if mfi {
if feature == "1" {
plainM1.Method = MethodPairMFi // ff=1 => method=1, ff=2 => method=0
}
res, err := c.Post(PathPairSetup, MimeTLV8, tlv8.MarshalReader(plainM1))
+9 -6
View File
@@ -9,6 +9,7 @@ import (
func TestPatch(t *testing.T) {
b := []byte(`# prefix`)
// 1. Add first
b, err := Patch(b, "camera1", "url1", "streams")
require.Nil(t, err)
@@ -17,6 +18,7 @@ streams:
camera1: url1
`, string(b))
// 2. Add second
b, err = Patch(b, "camera2", []string{"url2", "url3"}, "streams")
require.Nil(t, err)
@@ -28,6 +30,7 @@ streams:
- url3
`, string(b))
// 3. Replace first
b, err = Patch(b, "camera1", "url4", "streams")
require.Nil(t, err)
@@ -39,6 +42,7 @@ streams:
- url3
`, string(b))
// 4. Replace second
b, err = Patch(b, "camera2", "url5", "streams")
require.Nil(t, err)
@@ -48,6 +52,7 @@ streams:
camera2: url5
`, string(b))
// 5. Delete first
b, err = Patch(b, "camera1", nil, "streams")
require.Nil(t, err)
@@ -65,10 +70,8 @@ streams:
camera1: url1
`)
pairings := map[string]string{
"client1": "public1",
"client2": "public2",
}
// 1. Add new key
pairings := []string{"client1", "client2"}
b, err := Patch(b, "pairings", pairings, "homekit", "camera1")
require.Nil(t, err)
@@ -77,8 +80,8 @@ streams:
camera1:
pin: 123-45-678
pairings:
client1: public1
client2: public2
- client1
- client2
streams:
camera1: url1
`, string(b))
+86 -84
View File
@@ -59,7 +59,18 @@
<body>
<script src="main.js"></script>
<script>
async function getSources(url, tableID) {
function drawTable(table, data) {
const cols = ['id', 'name', 'info', 'url', 'location'];
const th = (row) => cols.reduce((html, k) => k in row ? `${html}<th>${k}</th>` : html, '<tr>') + '</tr>';
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td>${row[k]}</td>` : html, '<tr>') + '</tr>';
const thead = th(data.sources[0]);
const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
table.innerHTML = `<thead>${thead}</thead><tbody>${tbody}</tbody>`;
}
async function getSources(tableID, url) {
const table = document.getElementById(tableID);
table.innerText = 'loading...';
@@ -69,16 +80,7 @@
return;
}
const td = value => value ? `<td>${value}</td>` : '';
const th = (name, value) => value ? `<th>${name}</th>` : '';
/** @type {{sources:Array<{id:string,name:string,url:string,location:string}>}} */
const data = await r.json();
const i0 = data.sources[0];
const thead = `<tr>${th('ID', i0.id)}${th('Name', i0.name)}${th('URL', i0.url)}${th('Location', i0.location)}</tr>`;
table.innerHTML = data.sources.reduce((html, item) => {
return `${html}<tr>${td(item.id)}${td(item.name)}${td(item.url)}${td(item.location)}</tr>`;
}, `<thead>${thead}</thead><tbody>`) + '</tbody>';
drawTable(table, await r.json());
}
</script>
@@ -104,76 +106,79 @@
url.searchParams.set('src', ev.target.elements['src'].value);
const r = await fetch(url, {method: 'PUT'});
alert(r.ok ? 'OK' : 'ERROR');
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
});
</script>
<button id="homekit">Apple HomeKit</button>
<div class="module">
<div style="margin-bottom: 10px">
<label for="pin">PIN</label>
<input id="pin" type="text">
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th>Model</th>
<th>Commands</th>
</tr>
</thead>
<tbody id="homekit-body">
</tbody>
</table>
<form id="homekit-pair" style="margin-bottom: 10px">
<input type="text" name="id" placeholder="stream id" size="20">
<input type="text" name="url" placeholder="url" size="40">
<input type="text" name="pin" placeholder="pin" size="10">
<input type="submit" value="Pair">
</form>
<form id="homekit-unpair" style="margin-bottom: 10px">
<input type="text" name="id" placeholder="stream id" size="20">
<input type="submit" value="Unpair">
</form>
<table id="homekit-table"></table>
</div>
<script>
async function reloadHomeKit() {
await getSources('homekit-table', 'api/homekit');
const rows = document.querySelectorAll('#homekit-table tr');
rows.forEach((row) => {
let commands = '';
if (row.children[2].innerText.indexOf('status=1') > 0) {
commands += '<a href="#">pair</a>';
} else if (row.children[3].innerText) {
commands += '<a href="#">unpair</a>';
}
row.innerHTML += `<td>${commands}</td>`;
});
}
document.getElementById('homekit').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block';
const r = await fetch('api/homekit', {cache: 'no-cache'});
/** @type {Array<{id:string,name:string,addr:string,model:string,paired:boolean}>} */
const data = await r.json();
const tbody = document.getElementById('homekit-body');
tbody.innerHTML =
data.reduce((res, item) => {
let commands = '';
if (item.id === '') {
commands = `<a href="#" onclick="unpair('${item.name}')">unpair</a>`;
} else if (item.paired === false) {
commands = `<a href="#" onclick="pair('${item.id}','${item.name}')">pair</a>`;
}
return res + `<tr>
<td>${item.name}</td>
<td>${item.addr}</td>
<td>${item.model}</td>
<td>${commands}</td>
</tr>`;
}, '');
await reloadHomeKit();
});
function pair(id, name) {
const pin = document.querySelector('#pin').value;
fetch(`api/homekit?id=${id}&name=${name}&pin=${pin}`, {method: 'POST'})
.then(r => r.text())
.then(data => {
if (data.length > 0) alert(data);
else window.location.reload();
})
.catch(console.error);
}
document.getElementById('homekit-table').addEventListener('click', ev => {
if (ev.target.innerText === 'pair') {
const form = document.querySelector('#homekit-pair');
const row = ev.target.closest('tr');
form.children[0].value = row.children[0].innerText;
form.children[1].value = row.children[2].innerText;
} else if (ev.target.innerText === 'unpair') {
const form = document.querySelector('#homekit-unpair');
const row = ev.target.closest('tr');
form.children[0].value = row.children[3].innerText;
}
});
function unpair(src) {
fetch(`api/homekit?src=${src}`, {method: 'DELETE'})
.then(r => r.text())
.then(data => {
if (data.length > 0) alert(data);
else window.location.reload();
})
.catch(console.error);
}
document.getElementById('homekit-pair').addEventListener('submit', async ev => {
ev.preventDefault();
const body = new FormData(ev.target);
body.set('url', body.get('url') + '&pin=' + body.get('pin'));
body.delete('pin');
const r = await fetch('api/homekit', {method: 'POST', body: body});
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
await reloadHomeKit();
});
document.getElementById('homekit-unpair').addEventListener('submit', async ev => {
ev.preventDefault();
const r = await fetch('api/homekit', {method: 'DELETE', body: new FormData(ev.target)});
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
await reloadHomeKit();
});
</script>
@@ -184,33 +189,31 @@
<script>
document.getElementById('dvrip').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block';
await getSources('api/dvrip', 'dvrip-table');
await getSources('dvrip-table', 'api/dvrip');
});
</script>
<button id="devices">FFmpeg Devices (USB)</button>
<div class="module">
<table id="devices-table">
</table>
<table id="devices-table"></table>
</div>
<script>
document.getElementById('devices').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block';
await getSources('api/ffmpeg/devices', 'devices-table');
await getSources('devices-table', 'api/ffmpeg/devices');
});
</script>
<button id="hardware">FFmpeg Hardware</button>
<div class="module">
<table id="hardware-table">
</table>
<table id="hardware-table"></table>
</div>
<script>
document.getElementById('hardware').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block';
await getSources('api/ffmpeg/hardware', 'hardware-table');
await getSources('hardware-table', 'api/ffmpeg/hardware');
});
</script>
@@ -224,8 +227,7 @@
<input type="text" name="project_id" placeholder="project_id">
<input type="submit" value="Login">
</form>
<table id="nest-table">
</table>
<table id="nest-table"></table>
</div>
<script>
document.getElementById('nest').addEventListener('click', async ev => {
@@ -239,7 +241,7 @@
const url = new URL('api/nest?' + query.toString(), location.href);
const r = await fetch(url, {cache: 'no-cache'});
await getSources(r, 'nest-table');
await getSources('nest-table', r);
});
</script>
@@ -251,7 +253,7 @@
<script>
document.getElementById('hass').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block';
await getSources('api/hass', 'hass-table');
await getSources('hass-table', 'api/hass');
});
</script>
@@ -267,7 +269,7 @@
<script>
document.getElementById('onvif').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block';
await getSources('api/onvif', 'onvif-table');
await getSources('onvif-table', 'api/onvif');
});
document.getElementById('onvif-form').addEventListener('submit', async ev => {
@@ -276,7 +278,7 @@
const url = new URL('api/onvif', location.href);
url.searchParams.set('src', ev.target.elements['src'].value);
await getSources(url.toString(), 'onvif-table');
await getSources('onvif-table', url.toString());
});
</script>
@@ -294,13 +296,13 @@
<script>
document.getElementById('roborock').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block';
await getSources('api/roborock', 'roborock-table');
await getSources('roborock-table', 'api/roborock');
});
document.getElementById('roborock-form').addEventListener('submit', async ev => {
ev.preventDefault();
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
await getSources(r, 'roborock-table');
await getSources('roborock-table', r);
});
</script>
@@ -312,7 +314,7 @@
<script>
document.getElementById('webtorrent').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block';
await getSources('api/webtorrent', 'webtorrent-table');
await getSources('webtorrent-table', 'api/webtorrent');
});
</script>