Compare commits

..

30 Commits

Author SHA1 Message Date
0a41b1072a Add LICENSE.md 2025-06-14 22:14:36 +02:00
d7b8ad9976 feat: add editing of remotes 2025-04-15 18:47:43 +02:00
6a9c69a535 feat: add edit command 2025-04-15 16:04:38 +02:00
8f3832657b feat: add download remotes as file 2025-04-15 00:26:34 +02:00
e09d42ecf6 feat: add toggle to display remote control 2025-04-14 23:56:51 +02:00
23245dd549 feat: add remote control to integration view 2025-04-14 23:39:34 +02:00
b0c3a48da6 refactor: improve command constants 2025-04-14 23:38:51 +02:00
f83ffac0ce feat: add get remotes in web interface 2025-04-14 23:37:07 +02:00
83ab71f845 feat: add get remote api endpoint 2025-04-14 23:34:59 +02:00
6940782024 feat: add data channel to webrtc connection 2025-04-14 13:59:59 +02:00
6cefa7392c feat: persisting remotes and commands in database 2025-04-12 16:35:03 +02:00
db1beac033 feat: add sub menu navigation 2025-04-10 22:22:42 +02:00
318185bd4a feat: change objects to instances passed to remotes service 2025-04-10 20:02:10 +02:00
7a20ae1536 feat: add import commands modal 2025-04-10 19:46:54 +02:00
3ba3f5efd5 feat: change dropdown to select in create command 2025-04-08 20:54:16 +02:00
820caf137f feat: add delete remotes 2025-04-08 20:39:34 +02:00
0c6a302a92 fix: new command not listed in create remote modal 2025-04-08 20:07:42 +02:00
01aff1635d feat: implement list-manager 2025-04-08 19:52:57 +02:00
3a33f82220 feat: add create remote modal 2025-04-07 15:44:06 +02:00
62ced07731 refactor: rename modules to components 2025-04-07 15:30:29 +02:00
70866b1a53 feat: add delete command modal 2025-04-07 14:45:43 +02:00
9a59326afa feat: add create command modal 2025-04-07 14:25:53 +02:00
dbaa7a768c feat: add remotes and commands data types and lists 2025-04-07 01:08:56 +02:00
bb9f616428 feat: add remotes and commands views 2025-04-07 00:45:35 +02:00
3382579a64 feat: add disconnect from webrtc connection 2025-04-07 00:16:49 +02:00
6d2fca696e feat: add thread safety to web socket list 2025-04-06 18:47:51 +02:00
4e639fb387 feat: add webrtc signaling and connection 2025-04-04 21:08:22 +02:00
717aaf1463 chore: add Makefile 2025-04-01 08:22:13 +02:00
811e348697 feat: add proper session authentication for user and integration 2025-03-31 13:23:42 +02:00
e4384bdbfb feat: add websocket server 2025-03-30 16:25:56 +02:00
67 changed files with 4269 additions and 532 deletions

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Fritz Heiden
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

11
Makefile Normal file
View File

@ -0,0 +1,11 @@
build: build-server build-web-interface
echo "done"
build-server:
CGO_ENABLED=1 go build -C "./main" -o ../start
build-server-start: build-server
chmod +x ./start; ./start
build-web-interface:
cd www && npm run build

View File

@ -1,4 +0,0 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CGO_ENABLED=1 go build -C "$SCRIPT_DIR/main" -o ../start

75
data/command.go Normal file
View File

@ -0,0 +1,75 @@
package data
const SAMSUNG_PROTOCOL = "samsung"
const NEC_PROTOCOL = "nec"
const ONKYO_PROTOCOL = "onkyo"
const APPLE_PROTOCOL = "apple"
const DENON_PROTOCOL = "denon"
const SHARP_PROTOCOL = "sharp"
const PANASONIC_PROTOCOL = "panasonic"
const KASEIKYO_PROTOCOL = "kaseikyo"
const JVC_PROTOCOL = "jvc"
const LG_PROTOCOL = "lg"
const SONY_PROTOCOL = "sony"
const RC5_PROTOCOL = "rc5"
const RC6_PROTOCOL = "rc6"
const UNIVERSAL_PULSE_DISTANCE_PROTOCOL = "universal_pulse_distance"
const UNIVERSAL_PULSE_WIDTH_PROTOCOL = "universal_pulse_width"
const UNIVERSAL_PULSE_DISTANCE_WIDTH_PROTOCOL = "universal_pulse_distance_width"
const HASH_PROTOCOL = "hash"
const PRONTO_PROTOCOL = "pronto"
const BOSE_WAVE_PROTOCOL = "bose_wave"
const BANG_OLUFSEN_PROTOCOL = "bang_olufsen"
const LEGO_PROTOCOL = "lego"
const FAST_PROTOCOL = "fast"
const WHYNTER_PROTOCOL = "whynter"
const MAGIQUEST_PROTOCOL = "magiquest"
const POWER_COMMAND_TYPE = "power"
const INPUT_COMMAND_TYPE = "input"
const ONE_COMMAND_TYPE = "1"
const TWO_COMMAND_TYPE = "2"
const THREE_COMMAND_TYPE = "3"
const FOUR_COMMAND_TYPE = "4"
const FIVE_COMMAND_TYPE = "5"
const SIX_COMMAND_TYPE = "6"
const SEVEN_COMMAND_TYPE = "7"
const EIGHT_COMMAND_TYPE = "8"
const NINE_COMMAND_TYPE = "9"
const ZERO_COMMAND_TYPE = "0"
const VOLUME_UP_COMMAND_TYPE = "volume_up"
const VOLUME_DOWN_COMMAND_TYPE = "volume_down"
const MUTE_COMMAND_TYPE = "mute"
const CHANNEL_UP_COMMAND_TYPE = "channel_up"
const CHANNEL_DOWN_COMMAND_TYPE = "channel_down"
const MENU_COMMAND_TYPE = "menu"
const HOME_COMMAND_TYPE = "home"
const SETTINGS_COMMAND_TYPE = "settings"
const OPTIONS_COMMAND_TYPE = "options"
const UP_COMMAND_TYPE = "up"
const DOWN_COMMAND_TYPE = "down"
const LEFT_COMMAND_TYPE = "left"
const RIGHT_COMMAND_TYPE = "right"
const ENTER_COMMAND_TYPE = "enter"
const INFO_COMMAND_TYPE = "info"
const RETURN_COMMAND_TYPE = "return"
const EXIT_COMMAND_TYPE = "exit"
const RED_COMMAND_TYPE = "red"
const GREEN_COMMAND_TYPE = "green"
const YELLOW_COMMAND_TYPE = "yellow"
const BLUE_COMMAND_TYPE = "blue"
const REWIND_COMMAND_TYPE = "rewind"
const PLAY_COMMAND_TYPE = "play"
const PAUSE_COMMAND_TYPE = "pause"
const STOP_COMMAND_TYPE = "stop"
const FORWARD_COMMAND_TYPE = "forward"
const OTHER_COMMAND_TYPE = "other"
type Command struct {
Id string `json:"id"`
Protocol string `json:"protocol"`
CommandNumber int `json:"commandNumber"`
Device int `json:"device"`
CommandType string `json:"commandType"`
Title string `json:"title"`
}

View File

@ -110,12 +110,7 @@ func (db *DeviceDatabase) CreateIntegration(name, token string) (string, error)
return "", err
}
hashed_token, err := hashPassword(token)
if err != nil {
return "", err
}
_, err = db.Connection.Exec("INSERT INTO integrations (id, name, token) VALUES (?, ?, ?)", id, name, hashed_token)
_, err = db.Connection.Exec("INSERT INTO integrations (id, name, token) VALUES (?, ?, ?)", id, name, token)
if err != nil {
return "", err
}
@ -163,6 +158,22 @@ func (db *DeviceDatabase) GetIntegrations() ([]Integration, error) {
return integrations, nil
}
func (db *DeviceDatabase) GetIntegrationByToken(token string) (*Integration, error) {
exists, err := db.IntegrationTokenExists(token)
if err != nil {
return nil, err
}
if !exists {
return nil, nil
}
var integration Integration
err = db.Connection.QueryRow("SELECT id, name FROM integrations WHERE token = ?", token).Scan(&integration.ID, &integration.Name)
if err != nil {
return nil, err
}
return &integration, nil
}
func (db *DeviceDatabase) DeleteIntegration(id string) error {
_, err := db.Connection.Exec("DELETE FROM integrations WHERE id = ?", id)
return err
@ -177,22 +188,13 @@ func (db *DeviceDatabase) IntegrationNameExists(name string) (bool, error) {
return exists, nil
}
func (db *DeviceDatabase) GetSession(sessionToken string) (*DeviceSession, error) {
var session DeviceSession
row := db.Connection.QueryRow("SELECT token, device_id, expiry_date FROM sessions WHERE token = ?", sessionToken)
err := row.Scan(&session.Token, &session.DeviceID, &session.ExpiryDate)
func (db *DeviceDatabase) IntegrationTokenExists(token string) (bool, error) {
var exists bool
err := db.Connection.QueryRow("SELECT EXISTS(SELECT 1 FROM integrations WHERE token = ?)", token).Scan(&exists)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
return false, err
}
return &session, nil
}
func (db *DeviceDatabase) DeleteSessionByToken(token string) error {
_, err := db.Connection.Exec("DELETE FROM sessions WHERE token = ?", token)
return err
return exists, nil
}
func (db *DeviceDatabase) SetDirectory(directory string) {

View File

@ -1,9 +0,0 @@
package data
import "time"
type DeviceSession struct {
DeviceID string
Token string
ExpiryDate time.Time
}

7
data/remote.go Normal file
View File

@ -0,0 +1,7 @@
package data
type Remote struct {
Id string `json:"id"`
Title string `json:"title"`
Commands []Command `json:"commands"`
}

255
data/remote_database.go Normal file
View File

@ -0,0 +1,255 @@
package data
import (
"database/sql"
"fmt"
"path/filepath"
gonanoid "github.com/matoous/go-nanoid"
)
type RemoteDatabase struct {
Connection *sql.DB
databaseDirectory string
}
func (db *RemoteDatabase) Initialize() error {
connection, error := sql.Open("sqlite3", filepath.Join(db.databaseDirectory, "remotes.db"))
if error != nil {
return error
}
db.Connection = connection
_, error = db.Connection.Exec(`PRAGMA foreign_keys = ON;`)
if error != nil {
return fmt.Errorf("error enabling foreign keys: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS Remotes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL
);`)
if error != nil {
return fmt.Errorf("error creating remotes table: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS Commands (
id TEXT PRIMARY KEY,
protocol TEXT,
commandNumber INTEGER,
device INTEGER,
commandType TEXT,
title TEXT
);`)
if error != nil {
return fmt.Errorf("error creating commands table: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS RemoteCommands (
remote_id TEXT NOT NULL,
command_id TEXT NOT NULL,
PRIMARY KEY (remote_id, command_id),
FOREIGN KEY (remote_id) REFERENCES Remotes(id) ON DELETE CASCADE,
FOREIGN KEY (command_id) REFERENCES Commands(id) ON DELETE CASCADE
);`)
if error != nil {
return fmt.Errorf("error creating remote-commands table: %s", error)
}
return nil
}
func (db *RemoteDatabase) Close() error {
return db.Connection.Close()
}
func (db *RemoteDatabase) CreateRemote(remote Remote) (string, error) {
remoteId, err := gonanoid.Nanoid(8)
if err != nil {
return "", err
}
queryString := "INSERT INTO Remotes (id, title) VALUES (?, ?)"
_, err = db.Connection.Exec(queryString, remoteId, remote.Title)
if err != nil {
return "", err
}
commandIds := []string{}
for _, command := range remote.Commands {
commandIds = append(commandIds, command.Id)
}
err = db.CreateRemoteCommands(remoteId, commandIds)
if err != nil {
return "", err
}
return remoteId, nil
}
func (db *RemoteDatabase) CreateRemoteCommands(remoteId string, commandIds []string) error {
for _, commandId := range commandIds {
queryString := "INSERT INTO RemoteCommands (remote_id, command_id) VALUES (?, ?)"
_, err := db.Connection.Exec(queryString, remoteId, commandId)
if err != nil {
return err
}
}
return nil
}
func (db *RemoteDatabase) UpdateRemoteCommands(remoteId string, commandIds []string) error {
queryString := "DELETE FROM RemoteCommands WHERE remote_id = ?"
_, err := db.Connection.Exec(queryString, remoteId)
if err != nil {
return err
}
err = db.CreateRemoteCommands(remoteId, commandIds)
if err != nil {
return err
}
return nil
}
func (db *RemoteDatabase) GetRemote(remoteId string) (Remote, error) {
var remote Remote
queryString := "SELECT id, title FROM Remotes WHERE id = ?"
row := db.Connection.QueryRow(queryString, remoteId)
error := row.Scan(&remote.Id, &remote.Title)
if error != nil {
return Remote{}, fmt.Errorf("error scanning remote: %s", error)
}
commands, error := db.GetCommandsByRemoteId(remoteId)
if error != nil {
return Remote{}, error
}
remote.Commands = commands
return remote, nil
}
func (db *RemoteDatabase) GetRemotes() ([]Remote, error) {
rows, error := db.Connection.Query("SELECT id, title FROM Remotes")
if error != nil {
return nil, fmt.Errorf("error querying remotes: %s", error)
}
defer rows.Close()
remotes := []Remote{}
for rows.Next() {
var remote Remote
error = rows.Scan(&remote.Id, &remote.Title)
if error != nil {
return nil, fmt.Errorf("error scanning remote: %s", error)
}
remotes = append(remotes, remote)
}
return remotes, nil
}
func (db *RemoteDatabase) UpdateRemote(remote Remote) error {
queryString := "UPDATE Remotes SET title = ? WHERE id = ?"
_, error := db.Connection.Exec(queryString, remote.Title, remote.Id)
if error != nil {
return fmt.Errorf("error updating remote %s: %s", remote.Id, error)
}
commandIds := []string{}
for _, command := range remote.Commands {
commandIds = append(commandIds, command.Id)
}
err := db.UpdateRemoteCommands(remote.Id, commandIds)
if err != nil {
return err
}
return nil
}
func (db *RemoteDatabase) DeleteRemote(remoteId string) error {
queryString := "DELETE FROM Remotes WHERE id = ?"
_, error := db.Connection.Exec(queryString, remoteId)
if error != nil {
return fmt.Errorf("error deleting remote %s: %s", remoteId, error)
}
return nil
}
func (db *RemoteDatabase) GetCommandsByRemoteId(remoteId string) ([]Command, error) {
rows, error := db.Connection.Query("SELECT id, protocol, commandNumber, device, commandType, title FROM Commands WHERE id IN (SELECT command_id FROM RemoteCommands WHERE remote_id = ?)", remoteId)
if error != nil {
return nil, fmt.Errorf("error querying commands for remote %s: %s", remoteId, error)
}
defer rows.Close()
commands := []Command{}
for rows.Next() {
var command Command
error = rows.Scan(&command.Id, &command.Protocol, &command.CommandNumber, &command.Device, &command.CommandType, &command.Title)
if error != nil {
return nil, fmt.Errorf("error scanning command: %s", error)
}
commands = append(commands, command)
}
return commands, nil
}
func (db *RemoteDatabase) CreateCommand(command Command) (string, error) {
commandId, err := gonanoid.Nanoid(8)
if err != nil {
return "", err
}
queryString := "INSERT INTO Commands (id, protocol, commandNumber, device, commandType, title) VALUES (?, ?, ?, ?, ?, ?)"
_, err = db.Connection.Exec(queryString, commandId, command.Protocol, command.CommandNumber, command.Device, command.CommandType, command.Title)
if err != nil {
return "", err
}
return commandId, nil
}
func (db *RemoteDatabase) CreateCommands(commands []Command) error {
for _, command := range commands {
_, err := db.CreateCommand(command)
if err != nil {
return err
}
}
return nil
}
func (db *RemoteDatabase) GetCommands() ([]Command, error) {
rows, error := db.Connection.Query("SELECT id, protocol, commandNumber, device, commandType, title FROM Commands")
if error != nil {
return nil, fmt.Errorf("error querying commands: %s", error)
}
defer rows.Close()
commands := []Command{}
for rows.Next() {
var command Command
error = rows.Scan(&command.Id, &command.Protocol, &command.CommandNumber, &command.Device, &command.CommandType, &command.Title)
if error != nil {
return nil, fmt.Errorf("error scanning command: %s", error)
}
commands = append(commands, command)
}
return commands, nil
}
func (db *RemoteDatabase) UpdateCommand(command Command) error {
queryString := "UPDATE Commands SET protocol = ?, commandNumber = ?, device = ?, commandType = ?, title = ? WHERE id = ?"
_, error := db.Connection.Exec(queryString, command.Protocol, command.CommandNumber, command.Device, command.CommandType, command.Title, command.Id)
if error != nil {
return fmt.Errorf("error updating command %s: %s", command.Id, error)
}
return nil
}
func (db *RemoteDatabase) DeleteCommand(commandId string) error {
queryString := "DELETE FROM Commands WHERE id = ?"
_, error := db.Connection.Exec(queryString, commandId)
if error != nil {
return fmt.Errorf("error deleting command %s: %s", commandId, error)
}
return nil
}
func (db *RemoteDatabase) SetDirectory(directory string) {
db.databaseDirectory = directory
}

View File

@ -86,8 +86,15 @@ func (db *UserDatabase) GetUserByUsername(username string) (*User, error) {
}
func (db *UserDatabase) GetUserById(id string) (*User, error) {
exists, err := db.UserIdExists(id)
if err != nil {
return nil, err
}
if !exists {
return nil, nil
}
var user User
err := db.Connection.QueryRow("SELECT id, username, password, is_admin FROM users WHERE id = ?", id).Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin)
err = db.Connection.QueryRow("SELECT id, username, password, is_admin FROM users WHERE id = ?", id).Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin)
if err != nil {
return nil, err
}
@ -145,9 +152,16 @@ func (db *UserDatabase) CheckCredentials(username, password string) (bool, error
}
func (db *UserDatabase) GetSession(sessionToken string) (*UserSession, error) {
exists, err := db.SessionTokenExists(sessionToken)
if err != nil {
return nil, err
}
if !exists {
return nil, nil
}
var session UserSession
row := db.Connection.QueryRow("SELECT token, user_id, expiry_date FROM sessions WHERE token = ?", sessionToken)
err := row.Scan(&session.Token, &session.UserID, &session.ExpiryDate)
err = row.Scan(&session.Token, &session.UserID, &session.ExpiryDate)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
@ -157,6 +171,15 @@ func (db *UserDatabase) GetSession(sessionToken string) (*UserSession, error) {
return &session, nil
}
func (db *UserDatabase) SessionTokenExists(token string) (bool, error) {
var exists bool
err := db.Connection.QueryRow("SELECT EXISTS(SELECT 1 FROM sessions WHERE token = ?)", token).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
func (db *UserDatabase) DeleteSessionByToken(token string) error {
_, err := db.Connection.Exec("DELETE FROM sessions WHERE token = ?", token)
return err

21
go.mod
View File

@ -3,25 +3,18 @@ module playback-device-server
go 1.24.1
require (
github.com/google/uuid v1.6.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/gorilla/websocket v1.5.3
github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2 // indirect
github.com/matoous/go-nanoid v1.5.1 // indirect
github.com/matoous/go-nanoid v1.5.1
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/pion/dtls/v3 v3.0.1 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/mattn/go-sqlite3 v1.14.24
github.com/rs/zerolog v1.33.0
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
github.com/ziflex/lecho/v3 v3.7.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
github.com/ziflex/lecho/v3 v3.7.0
golang.org/x/crypto v0.36.0
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect

16
go.sum
View File

@ -2,6 +2,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@ -17,18 +19,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pion/dtls/v3 v3.0.1 h1:0kmoaPYLAo0md/VemjcrAXQiSf8U+tuU3nDYVNpEKaw=
github.com/pion/dtls/v3 v3.0.1/go.mod h1:dfIXcFkKoujDQ+jtd8M6RgqKK3DuaUilm3YatAbGp5k=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
@ -37,8 +27,6 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/ziflex/lecho/v3 v3.7.0 h1:MSzYINEHtAaCx2XpbdF1A85aSyXitNJxF4T9dG6jzRQ=
github.com/ziflex/lecho/v3 v3.7.0/go.mod h1:LBlLsyIwa0MFxtJ2WU5WzHfuMR/jnq26TXddWfJ+s/0=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=

View File

@ -39,6 +39,15 @@ func main() {
}
defer deviceDatabase.Close()
remoteDatabase := data.RemoteDatabase{}
remoteDatabase.SetDirectory(configuration.DatabaseDirectory)
err = remoteDatabase.Initialize()
if err != nil {
log.Error().Err(err).Msg("failed to initialize remote database")
os.Exit(1)
}
defer remoteDatabase.Close()
userManager := management.UserManager{}
err = userManager.Initialize(&userDatabase)
if err != nil {
@ -51,6 +60,12 @@ func main() {
log.Error().Err(err).Msg("failed to initialize device manager")
}
remoteManager := management.RemoteManager{}
err = remoteManager.Initialize(&remoteDatabase)
if err != nil {
log.Error().Err(err).Msg("failed to initialize remote manager")
}
webServer := server.WebServer{}
webServer.SetWebAppDirectoryPath("www")
webServer.SetPort(configuration.Port)
@ -59,6 +74,7 @@ func main() {
authenticator := server.Authenticator{}
authenticator.SetUserManager(&userManager)
authenticator.SetDeviceManager(&deviceManager)
userApiHandler := server.UsersApiHandler{}
userApiHandler.SetUserManager(&userManager)
@ -70,11 +86,17 @@ func main() {
deviceApiHandler.SetRouter(webServer.Router())
deviceApiHandler.Initialize(&authenticator)
turnServer := server.TurnServer{}
turnServer.SetPublicIp("192.168.178.23")
turnServer.SetPort(3478)
turnServer.SetRealm("pbrts.net")
turnServer.AddUser("user", "password")
remoteApiHandler := server.RemoteApiHandler{}
remoteApiHandler.SetRemoteManager(&remoteManager)
remoteApiHandler.SetRouter(webServer.Router())
remoteApiHandler.Initialize(&authenticator)
webSocketServer := server.WebsocketServer{}
webSocketServer.SetRouter(webServer.Router())
webSocketServer.Initialize(&authenticator)
webRTCWSHandler := server.WebRTCWSHandler{}
webSocketServer.AddHandler(&webRTCWSHandler)
var wg sync.WaitGroup
wg.Add(1)
@ -86,15 +108,6 @@ func main() {
log.Error().Err(err).Msg("failed to start web server")
}
}()
go func() {
defer wg.Done()
log.Info().Msg("starting turn server")
err := turnServer.Start()
if err != nil {
log.Error().Err(err).Msg("failed to start turn server")
}
}()
wg.Wait()
}

View File

@ -49,14 +49,6 @@ func (dm *DeviceManager) DeviceIdExists(id string) (bool, error) {
// return token, nil
//}
func (dm *DeviceManager) GetSession(sessionToken string) (*d.DeviceSession, error) {
session, error := dm.deviceDatabase.GetSession(sessionToken)
if error != nil {
return nil, error
}
return session, nil
}
func (dm *DeviceManager) GetDeviceById(id string) (*d.PlaybackDevice, error) {
device, error := dm.deviceDatabase.GetDeviceById(id)
if error != nil {
@ -70,14 +62,6 @@ func (dm *DeviceManager) UpdateDevice(device *d.PlaybackDevice) error {
return error
}
func (dm *DeviceManager) DeleteSession(token string) error {
error := dm.deviceDatabase.DeleteSessionByToken(token)
if error != nil {
return error
}
return nil
}
func (dm *DeviceManager) GetDevices() (*[]d.PlaybackDevice, error) {
users, error := dm.deviceDatabase.GetDevices()
return users, error
@ -141,6 +125,14 @@ func (dm *DeviceManager) GetIntegrations() ([]d.Integration, error) {
return integrations, nil
}
func (dm *DeviceManager) GetIntegrationByToken(token string) (*d.Integration, error) {
integration, err := dm.deviceDatabase.GetIntegrationByToken(token)
if err != nil {
return nil, err
}
return integration, nil
}
func (dm *DeviceManager) DeleteIntegration(id string) error {
error := dm.deviceDatabase.DeleteIntegration(id)
return error

View File

@ -0,0 +1,66 @@
package management
import (
d "playback-device-server/data"
)
type RemoteManager struct {
remoteDatabase *d.RemoteDatabase
}
func (rm *RemoteManager) Initialize(remoteDatabase *d.RemoteDatabase) error {
rm.remoteDatabase = remoteDatabase
return nil
}
func (rm *RemoteManager) CreateRemote(remote d.Remote) (string, error) {
return rm.remoteDatabase.CreateRemote(remote)
}
func (rm *RemoteManager) GetRemote(remoteID string) (d.Remote, error) {
remote, err := rm.remoteDatabase.GetRemote(remoteID)
if err != nil {
return d.Remote{}, err
}
return remote, nil
}
func (rm *RemoteManager) GetRemotes() ([]d.Remote, error) {
remotes, err := rm.remoteDatabase.GetRemotes()
if err != nil {
return nil, err
}
return remotes, nil
}
func (rm *RemoteManager) UpdateRemote(remote d.Remote) error {
return rm.remoteDatabase.UpdateRemote(remote)
}
func (rm *RemoteManager) DeleteRemote(remoteID string) error {
return rm.remoteDatabase.DeleteRemote(remoteID)
}
func (rm *RemoteManager) CreateCommand(command d.Command) (string, error) {
return rm.remoteDatabase.CreateCommand(command)
}
func (rm *RemoteManager) CreateCommands(commands []d.Command) error {
return rm.remoteDatabase.CreateCommands(commands)
}
func (rm *RemoteManager) GetCommands() ([]d.Command, error) {
return rm.remoteDatabase.GetCommands()
}
func (rm *RemoteManager) UpdateCommand(command d.Command) error {
return rm.remoteDatabase.UpdateCommand(command)
}
func (rm *RemoteManager) SetRemoteDatabase(remoteDatabase *d.RemoteDatabase) {
rm.remoteDatabase = remoteDatabase
}
func (rm *RemoteManager) DeleteCommand(commandID string) error {
return rm.remoteDatabase.DeleteCommand(commandID)
}

View File

@ -12,18 +12,24 @@ import (
type AuthContext struct {
echo.Context
User *d.User
Session *d.UserSession
User *d.User
Session *d.UserSession
Integration *d.Integration
}
type Authenticator struct {
userManager *m.UserManager
userManager *m.UserManager
deviceManager *m.DeviceManager
}
func (r *Authenticator) SetUserManager(userManager *m.UserManager) {
r.userManager = userManager
}
func (r *Authenticator) SetDeviceManager(deviceManager *m.DeviceManager) {
r.deviceManager = deviceManager
}
func (r *Authenticator) Authenticate(path string, exceptions []string) func(next echo.HandlerFunc) echo.HandlerFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(context echo.Context) error {
@ -38,29 +44,57 @@ func (r *Authenticator) Authenticate(path string, exceptions []string) func(next
}
cookie, err := context.Cookie("token")
if err != nil {
SendError(401, context, "no session token found")
SendError(401, context, "no cookie for session token found")
return err
}
session, error := r.userManager.GetSession(cookie.Value)
if error != nil || session == nil {
SendError(401, context, fmt.Sprintf("session not found: %s", cookie.Value))
return fmt.Errorf("session not found: %s", cookie.Value)
}
user, error := r.userManager.GetUserById(session.UserID)
token := cookie.Value
user, session, error := r.getUserAndSession(token)
if error != nil {
log.Error().Err(error).Msg("error getting user by id")
SendError(401, context, "no user found for given session")
log.Error().Err(error).Msg("error authenticating user")
SendError(500, context, fmt.Sprintf("error authenticating user: %s", error))
return error
}
if user == nil {
SendError(401, context, "no user found for given session")
return fmt.Errorf("no user found for session '%s'", cookie.Value)
integration, error := r.getIntegration(token)
if error != nil {
log.Error().Err(error).Msg("error getting integration")
SendError(500, context, fmt.Sprintf("error getting integration: %s", error))
return error
}
authContext := AuthContext{Context: context, User: user, Session: session}
if integration == nil && user == nil {
log.Error().Msg("no integration or user found for given token")
SendError(401, context, "no integration or user found for given token")
return fmt.Errorf("no integration or user found for given token")
}
authContext := AuthContext{Context: context, User: user, Session: session, Integration: integration}
return next(authContext)
}
}
}
func (r *Authenticator) getUserAndSession(token string) (*d.User, *d.UserSession, error) {
session, error := r.userManager.GetSession(token)
if error != nil {
return nil, nil, error
}
if session == nil {
return nil, nil, nil
}
user, error := r.userManager.GetUserById(session.UserID)
if error != nil {
return nil, nil, error
}
return user, session, nil
}
func (r *Authenticator) getIntegration(token string) (*d.Integration, error) {
integration, error := r.deviceManager.GetIntegrationByToken(token)
if error != nil {
return nil, error
}
return integration, nil
}

View File

@ -2,6 +2,7 @@ package server
import (
"fmt"
"net/http"
d "playback-device-server/data"
m "playback-device-server/management"
@ -23,15 +24,17 @@ func (r *DeviceApiHandler) Initialize(authenticator *Authenticator) {
devicesApi.GET("", r.handleGetDevices)
devicesApi.POST("", r.handleCreateDevice)
devicesApi.DELETE("/:id", r.handleDeleteDevice)
r.router.Use(authenticator.Authenticate("/api/integrations", []string{"/api/integrations/register"}))
integrationsApi := r.router.Group("/api/integrations")
integrationsApi.GET("/register", r.handleIntegrationRegistration)
integrationsApi.POST("/register", r.handleIntegrationRegistration)
integrationsApi.POST("", r.handleCreateIntegration)
integrationsApi.GET("", r.handleGetIntegrations)
integrationsApi.GET("/:id", r.handleGetIntegration)
integrationsApi.DELETE("/:id", r.handleDeleteIntegration)
}
func (r *DeviceApiHandler) handleIntegrationRegistration(context echo.Context) error {
func (r *DeviceApiHandler) handleCreateIntegration(context echo.Context) error {
code, error := r.deviceManager.GetRegistrationCode()
if error != nil {
@ -48,7 +51,7 @@ func (r *DeviceApiHandler) handleIntegrationRegistration(context echo.Context) e
return context.JSON(200, response)
}
func (r *DeviceApiHandler) handleCreateIntegration(context echo.Context) error {
func (r *DeviceApiHandler) handleIntegrationRegistration(context echo.Context) error {
var data struct {
Name string `json:"name"`
Code string `json:"code"`
@ -77,6 +80,16 @@ func (r *DeviceApiHandler) handleCreateIntegration(context echo.Context) error {
Token: integration.Token,
}
thirdyDays := 30 * 24 * 60 * 60
cookie := &http.Cookie{
Name: "token",
Value: integration.Token,
Path: "/",
HttpOnly: true,
MaxAge: thirdyDays,
}
context.SetCookie(cookie)
return context.JSON(200, integrationData)
}

View File

@ -0,0 +1,146 @@
package server
import (
d "playback-device-server/data"
m "playback-device-server/management"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
)
type RemoteApiHandler struct {
router *echo.Echo
remoteManager *m.RemoteManager
}
func (r *RemoteApiHandler) Initialize(authenticator *Authenticator) {
r.router.Use(authenticator.Authenticate("/api/remotes", []string{}))
remotesApi := r.router.Group("/api/remotes")
remotesApi.GET("", r.handleGetRemotes)
remotesApi.GET("/:id", r.handleGetRemote)
remotesApi.POST("", r.handleCreateRemote)
remotesApi.PUT("/:id", r.handleUpdateRemote)
remotesApi.DELETE("/:id", r.handleDeleteRemote)
r.router.Use(authenticator.Authenticate("/api/commands", []string{}))
commandsApi := r.router.Group("/api/commands")
commandsApi.GET("", r.handleGetCommands)
commandsApi.POST("", r.handleCreateCommands)
commandsApi.PUT("/:id", r.handleUpdateCommand)
commandsApi.DELETE("/:id", r.handleDeleteCommand)
}
func (r *RemoteApiHandler) handleCreateRemote(context echo.Context) error {
remote := d.Remote{}
if err := context.Bind(&remote); err != nil {
SendError(400, context, err.Error())
return nil
}
id, err := r.remoteManager.CreateRemote(remote)
if err != nil {
SendError(500, context, err.Error())
return err
}
remote.Id = id
return context.JSON(200, remote)
}
func (r *RemoteApiHandler) handleGetRemote(context echo.Context) error {
id := context.Param("id")
remote, err := r.remoteManager.GetRemote(id)
if err != nil {
SendError(500, context, err.Error())
log.Error().Err(err)
return err
}
return context.JSON(200, remote)
}
func (r *RemoteApiHandler) handleGetRemotes(context echo.Context) error {
remotes, err := r.remoteManager.GetRemotes()
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, remotes)
}
func (r *RemoteApiHandler) handleUpdateRemote(context echo.Context) error {
remote := d.Remote{}
if err := context.Bind(&remote); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.UpdateRemote(remote)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleDeleteRemote(context echo.Context) error {
id := context.Param("id")
err := r.remoteManager.DeleteRemote(id)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleCreateCommands(context echo.Context) error {
commands := []d.Command{}
if err := context.Bind(&commands); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.CreateCommands(commands)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleGetCommands(context echo.Context) error {
commands, err := r.remoteManager.GetCommands()
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, commands)
}
func (r *RemoteApiHandler) handleUpdateCommand(context echo.Context) error {
command := d.Command{}
if err := context.Bind(&command); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.UpdateCommand(command)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleDeleteCommand(context echo.Context) error {
id := context.Param("id")
err := r.remoteManager.DeleteCommand(id)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) SetRouter(router *echo.Echo) {
r.router = router
}
func (r *RemoteApiHandler) SetRemoteManager(remoteManager *m.RemoteManager) {
r.remoteManager = remoteManager
}

View File

@ -1,92 +0,0 @@
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// Package main implements a simple TURN server
package server
import (
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"github.com/pion/turn/v4"
)
type TurnServer struct {
publicIp string
port int
users map[string][]byte
realm string
}
func (t *TurnServer) Start() error {
// Create a UDP listener to pass into pion/turn
// pion/turn itself doesn't allocate any UDP sockets, but lets the user pass them in
// this allows us to add logging, storage or modify inbound/outbound traffic
udpListener, err := net.ListenPacket("udp4", "0.0.0.0:"+strconv.Itoa(t.port))
if err != nil {
return fmt.Errorf("failed to create TURN server listener: %s", err)
}
server, err := turn.NewServer(turn.ServerConfig{
Realm: t.realm,
// Set AuthHandler callback
// This is called every time a user tries to authenticate with the TURN server
// Return the key for that user, or false when no user is found
AuthHandler: func(username string, realm string, srcAddr net.Addr) ([]byte, bool) { // nolint: revive
if key, ok := t.users[username]; ok {
return key, true
}
return nil, false
},
// PacketConnConfigs is a list of UDP Listeners and the configuration around them
PacketConnConfigs: []turn.PacketConnConfig{
{
PacketConn: udpListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{
// Claim that we are listening on IP passed by user (This should be your Public IP)
RelayAddress: net.ParseIP(t.publicIp),
// But actually be listening on every interface
Address: "0.0.0.0",
},
},
},
})
if err != nil {
return err
}
// Block until user sends SIGINT or SIGTERM
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
if err = server.Close(); err != nil {
return err
}
return nil
}
func (t *TurnServer) AddUser(username string, password string) {
if t.users == nil {
t.users = make(map[string][]byte)
}
t.users[username] = turn.GenerateAuthKey(username, t.realm, password)
}
func (t *TurnServer) SetPublicIp(ip string) {
t.publicIp = ip
}
func (t *TurnServer) SetPort(port int) {
t.port = port
}
func (t *TurnServer) SetRealm(realm string) {
t.realm = realm
}

View File

@ -0,0 +1,50 @@
package server
import (
"encoding/json"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
const TYPE_SIGNALING = "signaling"
type WebRTCWSHandler struct {
}
func (h *WebRTCWSHandler) Handle(messageObject map[string]any, senderId string, sockets map[string]*websocket.Conn) {
if _, ok := messageObject["type"]; !ok {
return
}
messageType := messageObject["type"]
switch messageType {
case TYPE_SIGNALING:
target, ok := messageObject["target"].(string)
if !ok {
log.Error().Msgf("Invalid target in signaling message")
return
}
message := messageObject["message"]
h.handleSignaling(target, message, senderId, sockets)
}
}
func (h *WebRTCWSHandler) handleSignaling(target string, message any, senderId string, sockets map[string]*websocket.Conn) {
ws, ok := sockets[target]
if !ok {
log.Error().Msgf("No connection found for target %s", target)
return
}
byteArray, err := json.Marshal(map[string]any{"sender": senderId, "message": message})
if err != nil {
log.Error().Msgf("Error marshaling signaling message: %s", err)
return
}
log.Info().Str("target", target).Str("sender", senderId).Msg("sending signaling message to target")
err = ws.WriteMessage(websocket.TextMessage, byteArray)
if err != nil {
log.Error().Msgf("Error writing signaling message to target: %s", err)
return
}
}

View File

@ -1,38 +0,0 @@
package server
import (
"playback-device-server/data"
"github.com/labstack/echo/v4"
"golang.org/x/net/websocket"
)
type WebSocketConnection struct {
connection *websocket.Conn
integration data.Integration
}
type WebSocket struct {
router *echo.Echo
connections []WebSocketConnection
}
func (s *WebSocket) Initializer() {
s.router.GET("/ws", s.handle)
}
func (s *WebSocket) handle(context echo.Context) error {
websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close()
s.connections = append(s.connections, WebSocketConnection{
connection: ws,
integration: data.Integration{},
})
}).ServeHTTP(context.Response(), context.Request())
return nil
}
func (s *WebSocket) SetRouter(router *echo.Echo) {
s.router = router
}

View File

@ -0,0 +1,79 @@
package server
import (
"encoding/json"
"sync"
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
)
var upgrader = websocket.Upgrader{}
type WebsocketHandler interface {
Handle(message map[string]any, senderId string, sockets map[string]*websocket.Conn)
}
type WebsocketServer struct {
router *echo.Echo
sockets map[string]*websocket.Conn
handlers []WebsocketHandler
socketsMutex *sync.Mutex
}
func (s *WebsocketServer) Initialize(authenticator *Authenticator) {
s.socketsMutex = &sync.Mutex{}
s.sockets = make(map[string]*websocket.Conn)
s.router.Use(authenticator.Authenticate("/ws", []string{}))
s.router.GET("/ws", s.handle)
}
func (s *WebsocketServer) handle(context echo.Context) error {
authContext := context.(AuthContext)
senderId := getAuthenticatedId(authContext)
ws, err := upgrader.Upgrade(context.Response(), context.Request(), nil)
s.socketsMutex.Lock()
s.sockets[senderId] = ws
s.socketsMutex.Unlock()
if err != nil {
return err
}
defer func() {
ws.Close()
s.socketsMutex.Lock()
delete(s.sockets, senderId)
s.socketsMutex.Unlock()
}()
for {
messageType, messageBytes, err := ws.ReadMessage()
if err != nil {
return err
}
if messageType == websocket.TextMessage {
var messageObject map[string]any
json.Unmarshal(messageBytes, &messageObject)
for _, handler := range s.handlers {
handler.Handle(messageObject, senderId, s.sockets)
}
}
}
}
func getAuthenticatedId(authContext AuthContext) string {
if authContext.User != nil {
return authContext.User.ID
}
if authContext.Integration != nil {
return authContext.Integration.ID
}
return ""
}
func (s *WebsocketServer) AddHandler(handler WebsocketHandler) {
s.handlers = append(s.handlers, handler)
}
func (s *WebsocketServer) SetRouter(router *echo.Echo) {
s.router = router
}

View File

@ -14,6 +14,9 @@
href="./src/lib/bootstrap-icons-1.11.3/font/bootstrap-icons.css"
rel="stylesheet"
/>
<script src="./lib/papaparse-5.5.2.min.js"></script>
<script src="./lib/fusejs-7.1.0.min.js"></script>
<script src="./lib/popper.min.js"></script>
<script src="./lib/bootstrap-5.3.3-dist/js/bootstrap.bundle.min.js"></script>
<title>Playback Device Server</title>
</head>

View File

@ -0,0 +1,183 @@
import { createMemo, createSignal, mergeProps } from "solid-js";
function ListManager(props) {
props = mergeProps(
{
items: [],
availableItems: [],
style: "",
itemsTitle: "Selected items",
availableItemsTitle: "Available items",
itemToString: () => "",
onItemSelect: () => {},
onItemDeselect: () => {},
itemsEqual: (a, b) => a === b,
},
props
);
const itemToString = (item) => props.itemToString(item);
const byLabel = (a, b) => itemToString(a).localeCompare(itemToString(b));
const [selectedAvailableItemIndex, setSelectedAvailableItemIndex] =
createSignal(-1);
const [selectedItemIndex, setSelectedItemIndex] = createSignal(-1);
const [itemsSearchString, setItemsSearchString] = createSignal("");
const [availableItemsSearchString, setAvailableItemsSearchString] =
createSignal("");
const itemsFuse = createMemo(
() =>
new Fuse(props.items, {
keys: [{ name: "label", getFn: (item) => props.itemToString(item) }],
})
);
const availableItemsFuse = createMemo(
() =>
new Fuse(props.availableItems, {
keys: [{ name: "label", getFn: (item) => props.itemToString(item) }],
})
);
const selectableAvailableItems = createMemo(() =>
(availableItemsSearchString()
? availableItemsFuse()
.search(availableItemsSearchString())
.map((item) => item.item)
: props.availableItems
).filter(
(availableItem) =>
!props.items.find((item) => props.itemsEqual(item, availableItem))
)
);
const selectableItems = createMemo(() =>
itemsSearchString()
? itemsFuse()
.search(itemsSearchString())
.map((item) => item.item)
: props.items
);
const canSelect = createMemo(
() =>
selectedAvailableItemIndex() >= 0 &&
selectedAvailableItemIndex() < selectableAvailableItems().length
);
const canDeselect = createMemo(
() =>
selectedItemIndex() >= 0 && selectedItemIndex() < selectableItems().length
);
function handleSelectItem() {
let itemIndex = selectedAvailableItemIndex();
let item = selectableAvailableItems()[itemIndex];
if (itemIndex === selectableAvailableItems().length - 1) {
setSelectedAvailableItemIndex(itemIndex - 1);
}
props.onItemSelect(item);
}
function handleDeselectItem() {
let itemIndex = selectedItemIndex();
let item = selectableItems()[itemIndex];
if (itemIndex === selectableItems().length - 1) {
setSelectedItemIndex(itemIndex - 1);
}
props.onItemDeselect(item);
}
function ListItem(props) {
props = mergeProps(
{ children: "", selected: false, onClick: () => {} },
props
);
return (
<div
class={"px-2" + (props.selected ? " bg-secondary" : "")}
role="button"
onClick={props.onClick}
>
{props.children}
</div>
);
}
function ItemList(props) {
props = mergeProps(
{
items: [],
onItemSelected: () => {},
selectedItemIndex: -1,
onSearchStringChange: () => {},
},
props
);
return (
<>
<div class="input-group input-group-sm">
<span
class="input-group-text border-bottom-0 rounded-bottom-0"
id="basic-addon1"
>
<i class="bi bi-search"></i>
</span>
<input
type="text"
class="form-control form-control-sm border-bottom-0 rounded-bottom-0"
onInput={(event) => props.onSearchStringChange(event.target.value)}
/>
</div>
<div class="rounded rounded-top-0 border bg-body flex-fill overflow-y-scroll">
{props.items.sort(byLabel).map((item, index) => (
<ListItem
onClick={() => props.onItemSelected(index)}
selected={index === props.selectedItemIndex}
>
{itemToString(item)}
</ListItem>
))}
</div>
</>
);
}
return (
<div class={"d-flex"} style={props.style}>
<div class="flex-shrink-1 w-50 d-flex flex-column">
<div class="px-2">{props.itemsTitle}</div>
<ItemList
items={selectableItems()}
onItemSelected={(index) => setSelectedItemIndex(index)}
selectedItemIndex={selectedItemIndex()}
onSearchStringChange={(value) => setItemsSearchString(value)}
/>
</div>
<div class="p-2 d-flex flex-column justify-content-center align-items-center">
<button
class="btn btn-sm btn-outline-secondary mb-2"
onClick={handleSelectItem}
disabled={!canSelect()}
>
<i class="bi bi-caret-left-fill"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary"
onClick={handleDeselectItem}
disabled={!canDeselect()}
>
<i class="bi bi-caret-right-fill"></i>
</button>
</div>
<div class="flex-shrink-1 w-50 d-flex flex-column">
<div class="px-2">{props.availableItemsTitle}</div>
<ItemList
items={selectableAvailableItems()}
onItemSelected={(index) => setSelectedAvailableItemIndex(index)}
selectedItemIndex={selectedAvailableItemIndex()}
onSearchStringChange={(value) => setAvailableItemsSearchString(value)}
/>
</div>
</div>
);
}
export default ListManager;

View File

@ -8,7 +8,17 @@ import {
} from "solid-js";
function List(props) {
props = mergeProps({ items: [], showHeader: true, selectable: true }, props);
props = mergeProps(
{
items: [],
showHeader: true,
selectable: true,
onListItemsSelect: () => {},
onLazyLoad: () => {},
onListItemClick: () => {},
},
props
);
const [listItems, setListItems] = createSignal([]);
const selectedItems = createMemo(() =>
listItems()
@ -31,16 +41,13 @@ function List(props) {
);
});
createEffect(() => {
if (!props.onListItemsSelect) return;
props.onListItemsSelect(selectedItems());
});
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (props.onLazyLoad) {
props.onLazyLoad();
}
props.onLazyLoad();
}
});
});
@ -61,7 +68,6 @@ function List(props) {
});
function handleListItemClick(item) {
if (!props.onListItemClick) return;
props.onListItemClick(item);
}

View File

@ -0,0 +1,180 @@
import { createEffect, createMemo, mergeProps } from "solid-js";
import Remote from "../data/remote";
import Command from "../data/command";
function RemoteControl(props) {
props = mergeProps(
{
remote: new Remote(),
onCommand: () => {},
},
props
);
const BUTTON_SIZE_REGULAR = "regular";
const BUTTON_SIZE_SMALL = "small";
const BUTTON_HEIGHT = 2;
const BUTTON_WIDTH = BUTTON_HEIGHT * 1.4;
const SMALL_BUTTON_HEIGHT = 1.5;
const SMALL_BUTTON_WIDTH = SMALL_BUTTON_HEIGHT * 1.3334;
const TYPES = Command.TYPES;
const commandMap = createMemo(() =>
props.remote
.getCommands()
.reduce(
(map, command) => ({ ...map, [command.getCommandType()]: command }),
{}
)
);
let layout = [
[TYPES.POWER, null, TYPES.INPUT],
[TYPES.ONE, TYPES.TWO, TYPES.THREE],
[TYPES.FOUR, TYPES.FIVE, TYPES.SIX],
[TYPES.SEVEN, TYPES.EIGHT, TYPES.NINE],
[TYPES.VOLUME_UP, TYPES.ZERO, TYPES.CHANNEL_UP],
[TYPES.VOLUME_DOWN, TYPES.MUTE, TYPES.CHANNEL_DOWN],
[TYPES.MENU, TYPES.HOME, TYPES.SETTINGS],
[TYPES.OPTIONS, TYPES.UP, TYPES.INFO],
[TYPES.LEFT, TYPES.ENTER, TYPES.RIGHT],
[TYPES.RETURN, TYPES.DOWN, TYPES.EXIT],
[TYPES.RED, TYPES.GREEN, TYPES.YELLOW, TYPES.BLUE],
[TYPES.PLAY, TYPES.PAUSE, TYPES.STOP],
[TYPES.REWIND, null, TYPES.FORWARD],
];
function toButtonProps(type) {
let mapping = {
[TYPES.POWER]: () => ({ icon: "bi-power", text: "" }),
[TYPES.INPUT]: () => ({ icon: "bi-box-arrow-in-right", text: "" }),
[TYPES.VOLUME_UP]: () => ({ icon: "bi-volume-up", text: "" }),
[TYPES.VOLUME_DOWN]: () => ({ icon: "bi-volume-down", text: "" }),
[TYPES.MUTE]: () => ({ icon: "bi-volume-mute", text: "" }),
[TYPES.CHANNEL_UP]: () => ({ icon: "bi-chevron-up", text: "" }),
[TYPES.CHANNEL_DOWN]: () => ({ icon: "bi-chevron-down", text: "" }),
[TYPES.HOME]: () => ({ icon: "bi-house-door", text: "" }),
[TYPES.SETTINGS]: () => ({ icon: "bi-gear", text: "" }),
[TYPES.INFO]: () => ({ icon: "bi-info-circle", text: "" }),
[TYPES.UP]: () => ({ icon: "bi-arrow-up", text: "" }),
[TYPES.DOWN]: () => ({ icon: "bi-arrow-down", text: "" }),
[TYPES.LEFT]: () => ({ icon: "bi-arrow-left", text: "" }),
[TYPES.RIGHT]: () => ({ icon: "bi-arrow-right", text: "" }),
[TYPES.ENTER]: () => ({ icon: "", text: "OK" }),
[TYPES.EXIT]: () => ({ icon: "", text: "EXIT" }),
[TYPES.OPTIONS]: () => ({ icon: "bi-list-task", text: "" }),
[TYPES.RETURN]: () => ({ icon: "bi-arrow-return-left", text: "" }),
[TYPES.ONE]: () => ({ icon: "", text: "1" }),
[TYPES.TWO]: () => ({ icon: "", text: "2" }),
[TYPES.THREE]: () => ({ icon: "", text: "3" }),
[TYPES.FOUR]: () => ({ icon: "", text: "4" }),
[TYPES.FIVE]: () => ({ icon: "", text: "5" }),
[TYPES.SIX]: () => ({ icon: "", text: "6" }),
[TYPES.SEVEN]: () => ({ icon: "", text: "7" }),
[TYPES.EIGHT]: () => ({ icon: "", text: "8" }),
[TYPES.NINE]: () => ({ icon: "", text: "9" }),
[TYPES.ZERO]: () => ({ icon: "", text: "0" }),
[TYPES.MENU]: () => ({ icon: "", text: "MENU" }),
[TYPES.PLAY]: () => ({ icon: "bi-play", text: "" }),
[TYPES.PAUSE]: () => ({ icon: "bi-pause", text: "" }),
[TYPES.STOP]: () => ({ icon: "bi-stop", text: "" }),
[TYPES.REWIND]: () => ({ icon: "bi-rewind", text: "" }),
[TYPES.FORWARD]: () => ({ icon: "bi-fast-forward", text: "" }),
[TYPES.RED]: () => ({
class: "bg-danger",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.GREEN]: () => ({
class: "bg-success",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.YELLOW]: () => ({
class: "bg-warning",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.BLUE]: () => ({
class: "bg-primary",
buttonSize: BUTTON_SIZE_SMALL,
}),
};
if (!(type in mapping)) return {};
let props = mapping[type]();
props.type = type;
props.text = props.text || "";
return props;
}
function PlaceholderButton() {
return (
<div
style={`width: ${BUTTON_WIDTH}em; height: ${BUTTON_HEIGHT}em; margin: 0.2em;`}
></div>
);
}
function RemoteButton(props) {
props = mergeProps(
{
onClick: () => {},
disabled: false,
class: "",
buttonSize: BUTTON_SIZE_REGULAR,
icon: "",
text: "",
},
props
);
let buttonWidth = BUTTON_WIDTH;
let buttonHeight = BUTTON_HEIGHT;
if (props.buttonSize === BUTTON_SIZE_SMALL) {
buttonWidth = SMALL_BUTTON_WIDTH;
buttonHeight = SMALL_BUTTON_HEIGHT;
}
let buttonFontSize = buttonHeight * 0.4;
let iconSize = buttonHeight * 0.5;
return (
<button
class={
"btn btn-dark d-flex justify-content-center align-items-center " +
props.class
}
style={`width: ${buttonWidth}em; height: ${buttonHeight}em; margin: 0.2em;`}
onClick={props.onClick}
disabled={props.disabled}
>
<i style={`font-size: ${iconSize}em;`} class={"bi " + props.icon} />
<span style={`font-size: ${buttonFontSize}em;`}>{props.text}</span>
</button>
);
}
return (
<div class="d-flex flex-column justify-content-center align-items-center p-1 bg-secondary rounded">
{layout.map((row) => (
<div class="d-flex flex-row justify-content-center align-items-center">
{row
.map(toButtonProps)
.map(({ type, class: className, buttonSize, icon, text }) =>
!type ? (
<PlaceholderButton />
) : (
<RemoteButton
class={className}
buttonSize={buttonSize}
onClick={() => props.onCommand(commandMap()[type])}
disabled={!commandMap()[type]}
icon={icon}
text={text}
/>
)
)}
</div>
))}
</div>
);
}
export default RemoteControl;

View File

@ -2,7 +2,13 @@ import { createSignal, mergeProps } from "solid-js";
function ValidatedTextInput(props) {
props = mergeProps(
{ type: "text", valid: true, onInput: () => {}, errorText: "" },
{
type: "text",
valid: true,
onInput: () => {},
errorText: "",
placeholder: "",
},
props
);
let [isActive, setActive] = createSignal(false);
@ -15,6 +21,7 @@ function ValidatedTextInput(props) {
}
id={props.id}
value={props.value}
placeholder={props.placeholder}
onInput={props.onInput}
onFocusOut={() => setActive(true)}
/>

231
www/src/data/command.js Normal file
View File

@ -0,0 +1,231 @@
function Command({
id = "",
protocol = "",
commandNumber = -1,
device = -1,
commandType = "",
title = "",
} = {}) {
if (typeof commandNumber !== "number")
throw new Error("Command number must be a number");
if (typeof device !== "number") throw new Error("Device must be a number");
let _id = id;
let _protocol = protocol;
let _commandNumber = commandNumber;
let _device = device;
let _commandType = commandType;
let _title = title;
function getId() {
return _id;
}
function setId(id) {
_id = id;
}
function getProtocol() {
return _protocol;
}
function setProtocol(protocol) {
_protocol = protocol;
}
function getCommandNumber() {
return _commandNumber;
}
function setCommandNumber(commandNumber) {
if (typeof commandNumber !== "number")
throw new Error("Command number must be a number");
_commandNumber = commandNumber;
}
function getDevice() {
return _device;
}
function setDevice(device) {
if (typeof device !== "number") throw new Error("Device must be a number");
_device = device;
}
function getCommandType() {
return _commandType;
}
function setCommandType(commandType) {
_commandType = commandType;
}
function getTitle() {
if (_title) return _title;
return `${Command.getTypeString(_commandType)} (${Command.getProtocolString(_protocol)})`;
}
function setTitle(title) {
_title = title;
}
return {
getId,
setId,
getProtocol,
setProtocol,
getCommandNumber,
setCommandNumber,
getDevice,
setDevice,
getCommandType,
setCommandType,
getTitle,
setTitle,
};
}
Command.PROTOCOLS = {
SAMSUNG: "samsung",
NEC: "nec",
ONKYO: "onkyo",
APPLE: "apple",
DENON: "denon",
SHARP: "sharp",
PANASONIC: "panasonic",
KASEIKYO: "kaseikyo",
JVC: "jvc",
LG: "lg",
SONY: "sony",
RC5: "rc5",
RC6: "rc6",
UNIVERSAL_PULSE_DISTANCE: "universal_pulse_distance",
UNIVERSAL_PULSE_WIDTH: "universal_pulse_width",
UNIVERSAL_PULSE_DISTANCE_WIDTH: "universal_pulse_distance_width",
HASH: "hash",
PRONTO: "pronto",
BOSE_WAVE: "bose_wave",
BANG_OLUFSEN: "bang_olufsen",
LEGO: "lego",
FAST: "fast",
WHYNTER: "whynter",
MAGIQUEST: "magiquest",
}
Command.getProtocolString = (protocol) => {
let mapping = {
[Command.PROTOCOLS.SAMSUNG]: "Samsung",
[Command.PROTOCOLS.NEC]: "NEC",
[Command.PROTOCOLS.ONKYO]: "Onkyo",
[Command.PROTOCOLS.APPLE]: "Apple",
[Command.PROTOCOLS.DENON]: "Denon",
[Command.PROTOCOLS.SHARP]: "Sharp",
[Command.PROTOCOLS.PANASONIC]: "Panasonic",
[Command.PROTOCOLS.KASEIKYO]: "Kaseikyo",
[Command.PROTOCOLS.JVC]: "JVC",
[Command.PROTOCOLS.LG]: "LG",
[Command.PROTOCOLS.SONY]: "Sony",
[Command.PROTOCOLS.RC5]: "RC5",
[Command.PROTOCOLS.RC6]: "RC6",
[Command.PROTOCOLS.UNIVERSAL_PULSE_DISTANCE]: "Universal Pulse Distance",
[Command.PROTOCOLS.UNIVERSAL_PULSE_WIDTH]: "Universal Pulse Width",
[Command.PROTOCOLS.UNIVERSAL_PULSE_DISTANCE_WIDTH]: "Universal Pulse Distance Width",
[Command.PROTOCOLS.HASH]: "Hash",
[Command.PROTOCOLS.PRONTO]: "Pronto",
[Command.PROTOCOLS.BOSE_WAVE]: "BoseWave",
[Command.PROTOCOLS.BANG_OLUFSEN]: "Bang & Olufsen",
[Command.PROTOCOLS.LEGO]: "Lego",
[Command.PROTOCOLS.FAST]: "FAST",
[Command.PROTOCOLS.WHYNTER]: "Whynter",
[Command.PROTOCOLS.MAGIQUEST]: "MagiQuest",
}
return mapping[protocol]
}
Command.TYPES = {
POWER: "power",
INPUT: "input",
ONE: "one",
TWO: "two",
THREE: "three",
FOUR: "four",
FIVE: "five",
SIX: "six",
SEVEN: "seven",
EIGHT: "eight",
NINE: "nine",
ZERO: "zero",
VOLUME_UP: "volume_up",
VOLUME_DOWN: "volume_down",
MUTE: "mute",
CHANNEL_UP: "channel_up",
CHANNEL_DOWN: "channel_down",
MENU: "menu",
HOME: "home",
SETTINGS: "settings",
OPTIONS: "options",
UP: "up",
DOWN: "down",
LEFT: "left",
RIGHT: "right",
ENTER: "enter",
INFO: "info",
RETURN: "return",
EXIT: "exit",
RED: "red",
GREEN: "green",
YELLOW: "yellow",
BLUE: "blue",
REWIND: "rewind",
PLAY: "play",
PAUSE: "pause",
STOP: "stop",
FORWARD: "forward",
OTHER: "other",
}
Command.getTypeString = (type) => {
let mapping = {
[Command.TYPES.POWER]: "Power",
[Command.TYPES.INPUT]: "Input",
[Command.TYPES.ONE]: "1",
[Command.TYPES.TWO]: "2",
[Command.TYPES.THREE]: "3",
[Command.TYPES.FOUR]: "4",
[Command.TYPES.FIVE]: "5",
[Command.TYPES.SIX]: "6",
[Command.TYPES.SEVEN]: "7",
[Command.TYPES.EIGHT]: "8",
[Command.TYPES.NINE]: "9",
[Command.TYPES.ZERO]: "0",
[Command.TYPES.VOLUME_UP]: "Volume Up",
[Command.TYPES.VOLUME_DOWN]: "Volume Down",
[Command.TYPES.MUTE]: "Mute",
[Command.TYPES.CHANNEL_UP]: "Channel Up",
[Command.TYPES.CHANNEL_DOWN]: "Channel Down",
[Command.TYPES.MENU]: "Menu",
[Command.TYPES.HOME]: "Home",
[Command.TYPES.SETTINGS]: "Settings",
[Command.TYPES.OPTIONS]: "Options",
[Command.TYPES.UP]: "Up",
[Command.TYPES.DOWN]: "Down",
[Command.TYPES.LEFT]: "Left",
[Command.TYPES.RIGHT]: "Right",
[Command.TYPES.ENTER]: "Enter",
[Command.TYPES.INFO]: "Info",
[Command.TYPES.RETURN]: "Return",
[Command.TYPES.EXIT]: "Exit",
[Command.TYPES.RED]: "Red",
[Command.TYPES.GREEN]: "Green",
[Command.TYPES.YELLOW]: "Yellow",
[Command.TYPES.BLUE]: "Blue",
[Command.TYPES.REWIND]: "Rewind",
[Command.TYPES.PLAY]: "Play",
[Command.TYPES.PAUSE]: "Pause",
[Command.TYPES.STOP]: "Stop",
[Command.TYPES.FORWARD]: "Forward",
[Command.TYPES.OTHER]: "Other",
}
return mapping[type]
}
export default Command;

40
www/src/data/remote.js Normal file
View File

@ -0,0 +1,40 @@
function Remote({ id = "", title = "", commands = [] } = {}) {
let _id = id;
let _title = title;
let _commands = commands;
function getId() {
return _id;
}
function setId(id) {
_id = id;
}
function getTitle() {
return _title;
}
function setTitle(title) {
_title = title;
}
function getCommands() {
return _commands;
}
function setCommands(commands) {
_commands = commands;
}
return {
getId,
setId,
getTitle,
setTitle,
getCommands,
setCommands,
};
}
export default Remote;

View File

@ -1,6 +1,8 @@
import PlaybackDevice from "./playback-device.js";
import User from "./user.js";
import Integration from "./integration.js";
import Command from "./command.js";
import Remote from "./remote.js";
const Serializer = (function () {
function deserializeUser(object) {
@ -30,6 +32,52 @@ const Serializer = (function () {
return objects.map((object) => deserializeIntegration(object));
}
function serializeCommand(command) {
return {
id: command.getId(),
protocol: command.getProtocol(),
commandNumber: command.getCommandNumber(),
device: command.getDevice(),
commandType: command.getCommandType(),
title: command.getTitle(),
};
}
function serializeCommands(commands) {
if (!commands) return [];
return commands.map((command) => serializeCommand(command));
}
function deserializeCommand(object) {
return new Command(object);
}
function deserializeCommands(objects) {
if (!objects) return [];
return objects.map((object) => deserializeCommand(object));
}
function serializeRemote(remote) {
return {
id: remote.getId(),
title: remote.getTitle(),
commands: serializeCommands(remote.getCommands()),
};
}
function deserializeRemote(object) {
return new Remote({
id: object.id,
title: object.title,
commands: deserializeCommands(object.commands),
});
}
function deserializeRemotes(objects) {
if (!objects) return [];
return objects.map((object) => deserializeRemote(object));
}
return {
deserializeUser,
deserializeUsers,
@ -37,6 +85,13 @@ const Serializer = (function () {
deserializeDevices,
deserializeIntegration,
deserializeIntegrations,
serializeCommand,
serializeCommands,
deserializeCommand,
deserializeCommands,
serializeRemote,
deserializeRemote,
deserializeRemotes,
};
})();

9
www/src/lib/fusejs-7.1.0.min.js vendored Normal file

File diff suppressed because one or more lines are too long

7
www/src/lib/papaparse-5.5.2.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
www/src/lib/popper.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,187 @@
import { createEffect, createMemo, createSignal } from "solid-js";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import RemotesService from "../services/remotes-service.js";
import Command from "../data/command.js";
const eventEmitter = new EventEmitter();
const COMMAND_CREATED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
function CreateCommandModal(props) {
const [protocol, setProtocol] = createSignal("");
const [commandNumber, setCommandNumber] = createSignal("");
const [device, setDevice] = createSignal("");
const [commandType, setCommandType] = createSignal("");
const [title, setTitle] = createSignal("");
const [error, setError] = createSignal("");
const isProtocolValid = createMemo(() => protocol() !== "");
const isCommandNumberValid = createMemo(
() => commandNumber() !== "" && !isNaN(commandNumber())
);
const isDeviceValid = createMemo(() => device() !== "" && !isNaN(device()));
const isCommandTypeValid = createMemo(() => commandType() !== "");
const isTitleValid = createMemo(
() => title() === "" || title().length >= MIN_TITLE_LENGTH
);
const isFormValid = createMemo(
() =>
isProtocolValid() &&
isCommandNumberValid() &&
isDeviceValid() &&
isCommandTypeValid() &&
isTitleValid()
);
async function handleCreateCommand() {
let command;
try {
command = await RemotesService.createCommand(
new Command({
protocol: protocol(),
commandNumber: parseInt(commandNumber()),
device: parseInt(device()),
commandType: commandType(),
title: title(),
})
);
} catch (e) {
setError(e.message);
return;
}
resetFields();
CreateCommandModal.Handler.hide();
eventEmitter.dispatchEvent(COMMAND_CREATED_EVENT, command);
}
function resetFields() {
setProtocol("");
setCommandNumber("");
setDevice("");
setCommandType("");
setTitle("");
setError("");
}
return (
<Modal
ref={props.ref}
id="createCommandModal"
modalTitle="New Command"
centered={true}
>
<div class="modal-body" style="overflow-y:inherit !important;">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="new_command_protocol" class="col-form-label col-sm-3">
Protocol
</label>
<div class="col-sm-9">
<select
class="form-select"
onChange={(e) => setProtocol(e.target.value)}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.PROTOCOLS).map((protocol) => (
<option value={protocol}>
{Command.getProtocolString(protocol)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
<label for="new_command_number" class="col-form-label col-sm-3">
Command Number
</label>
<ValidatedTextInput
class="col-sm-9"
id="new_command_number"
valid={isCommandNumberValid()}
value={commandNumber()}
onInput={(e) => setCommandNumber(e.target.value)}
errorText={"Command number must be a number"}
/>
</div>
<div class="mb-3 row">
<label
for="new_command_device_number"
class="col-form-label col-sm-3"
>
Device Number
</label>
<ValidatedTextInput
class="col-sm-9"
id="new_command_device_number"
valid={isDeviceValid()}
value={device()}
onInput={(e) => setDevice(e.target.value)}
errorText={"Device number must be a number"}
/>
</div>
<div class="mb-3 row">
<label for="new_command_protocol" class="col-form-label col-sm-3">
Command Type
</label>
<div class="col-sm-9">
<select
class="form-select"
onChange={(e) => setCommandType(e.target.value)}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.TYPES).map((commandType) => (
<option value={commandType}>
{Command.getTypeString(commandType)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
<label for="new_command_title" class="col-form-label col-sm-3">
Title
</label>
<ValidatedTextInput
class="col-sm-9"
id="new_command_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleCreateCommand}
class="btn btn-primary"
disabled={!isFormValid()}
>
Create
</button>
</div>
</Modal>
);
}
CreateCommandModal.Handler = new ModalHandler();
CreateCommandModal.onCommandCreated = (callback) =>
eventEmitter.on(COMMAND_CREATED_EVENT, callback);
export default CreateCommandModal;

View File

@ -1,7 +1,7 @@
import { createEffect, createSignal } from "solid-js";
import Modal from "./modal.jsx";
import EventEmitter from "../tools/event-emitter.js";
import ValidatedTextInput from "../modules/validated-text-input.jsx";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import ModalHandler from "./modal-handler.js";
import DeviceService from "../services/device-service.js";

View File

@ -0,0 +1,124 @@
import { createMemo, createResource, createSignal, onMount } from "solid-js";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import ListManager from "../components/list-manager.jsx";
import Remote from "../data/remote.js";
const eventEmitter = new EventEmitter();
const REMOTE_CREATED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const modalHandler = new ModalHandler();
function CreateRemoteModal(props) {
const [title, setTitle] = createSignal("");
const [commands, setCommands] = createSignal([]);
const [availableCommands, { refetch: refetchAvailableCommands }] =
createResource(RemotesService.getCommands);
const [error, setError] = createSignal("");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
const isFormValid = createMemo(() => isTitleValid());
modalHandler.onShow(() => {
refetchAvailableCommands();
});
async function handleCreateRemote() {
let remote;
try {
remote = await RemotesService.createRemote(
new Remote({
title: title(),
commands: commands(),
})
);
} catch (e) {
setError(e.message);
console.error(e);
return;
}
resetFields();
CreateRemoteModal.Handler.hide();
eventEmitter.dispatchEvent(REMOTE_CREATED_EVENT, remote);
}
function resetFields() {
setTitle("");
setCommands([]);
setError("");
}
function handleCommandSelect(item) {
setCommands([...commands(), item]);
}
function handleCommandDeselect(item) {
setCommands(
commands().filter((command) => command.getId() !== item.getId())
);
}
return (
<Modal
ref={props.ref}
id="createRemoteModal"
modalTitle="New Remote"
centered={true}
>
<div class="modal-body bg-body-tertiary">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="new_remote_title" class="col-form-label col-sm-1">
Title
</label>
<ValidatedTextInput
class="col-sm-11"
id="new_remote_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
<ListManager
style="height: 20em;"
items={commands()}
availableItems={availableCommands()}
itemToString={(command) =>
`${command.getTitle()} (${command.getProtocol()}:${command.getCommandType()})`
}
onItemSelect={handleCommandSelect}
onItemDeselect={handleCommandDeselect}
itemsTitle="Selected Commands"
availableItemsTitle="Available Commands"
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleCreateRemote}
class="btn btn-primary"
disabled={!isFormValid()}
>
Create
</button>
</div>
</Modal>
);
}
CreateRemoteModal.Handler = modalHandler;
CreateRemoteModal.onRemoteCreated = (callback) =>
eventEmitter.on(REMOTE_CREATED_EVENT, callback);
export default CreateRemoteModal;

View File

@ -2,7 +2,7 @@ import { createEffect, createSignal } from "solid-js";
import Modal from "./modal.jsx";
import UserService from "../services/user-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ValidatedTextInput from "../modules/validated-text-input.jsx";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import ModalHandler from "./modal-handler.js";
const [users, setUsers] = createSignal([]);

View File

@ -0,0 +1,66 @@
import { createSignal } from "solid-js";
import Command from "../data/command.js";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const [command, setCommand] = createSignal(new Command());
const eventEmitter = new EventEmitter();
const COMMAND_DELETED_EVENT = "success";
function DeleteCommandModal(props) {
const [error, setError] = createSignal("");
async function handleDeleteCommand() {
try {
await RemotesService.deleteCommand(command().getId());
} catch (e) {
setError(e.message);
throw e;
}
DeleteCommandModal.Handler.hide();
eventEmitter.dispatchEvent(COMMAND_DELETED_EVENT, command);
}
return (
<Modal
ref={props.ref}
id="deleteCommandModal"
modalTitle="Delete command"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<span>
Do you really want to delete the command {command().getTitle()} ({command().getId()})?
</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleDeleteCommand}
class="btn btn-danger"
>
Delete
</button>
</div>
</Modal>
);
}
DeleteCommandModal.Handler = new ModalHandler();
DeleteCommandModal.setCommand = setCommand;
DeleteCommandModal.onCommandDeleted = (callback) =>
eventEmitter.on(COMMAND_DELETED_EVENT, callback);
export default DeleteCommandModal;

View File

@ -1,12 +1,9 @@
import { createEffect, createSignal } from "solid-js";
import Modal from "./modal.jsx";
import UserService from "../services/user-service.js";
import EventEmitter from "../tools/event-emitter.js";
import User from "../data/user.js";
import ValidatedTextInput from "../modules/validated-text-input.jsx";
import ModalHandler from "./modal-handler.js";
import { createSignal } from "solid-js";
import Integration from "../data/integration.js";
import DeviceService from "../services/device-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const [integration, setIntegration] = createSignal(new Integration());
const eventEmitter = new EventEmitter();

View File

@ -0,0 +1,66 @@
import { createSignal } from "solid-js";
import Remote from "../data/remote.js";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const [remote, setRemote] = createSignal(new Remote());
const eventEmitter = new EventEmitter();
const REMOTE_DELETED_EVENT = "success";
function DeleteRemoteModal(props) {
const [error, setError] = createSignal("");
async function handleDeleteRemote() {
try {
await RemotesService.deleteRemote(remote().getId());
} catch (e) {
setError(e.message);
throw e;
}
DeleteRemoteModal.Handler.hide();
eventEmitter.dispatchEvent(REMOTE_DELETED_EVENT, remote);
}
return (
<Modal
ref={props.ref}
id="deleteRemoteModal"
modalTitle="Delete remote"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<span>
Do you really want to delete the remote {remote().getTitle()} ({remote().getId()})?
</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleDeleteRemote}
class="btn btn-danger"
>
Delete
</button>
</div>
</Modal>
);
}
DeleteRemoteModal.Handler = new ModalHandler();
DeleteRemoteModal.setRemote = setRemote;
DeleteRemoteModal.onRemoteDeleted = (callback) =>
eventEmitter.on(REMOTE_DELETED_EVENT, callback);
export default DeleteRemoteModal;

View File

@ -3,7 +3,7 @@ import Modal from "./modal.jsx";
import UserService from "../services/user-service.js";
import EventEmitter from "../tools/event-emitter.js";
import User from "../data/user.js";
import ValidatedTextInput from "../modules/validated-text-input.jsx";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import ModalHandler from "./modal-handler.js";
const [user, setUser] = createSignal(new User());

View File

@ -0,0 +1,199 @@
import { createEffect, createMemo, createSignal } from "solid-js";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import RemotesService from "../services/remotes-service.js";
import Command from "../data/command.js";
const eventEmitter = new EventEmitter();
const COMMAND_EDITED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const [command, setCommand] = createSignal(new Command());
function EditCommandModal(props) {
const [protocol, setProtocol] = createSignal("");
const [commandNumber, setCommandNumber] = createSignal("");
const [device, setDevice] = createSignal("");
const [commandType, setCommandType] = createSignal("");
const [title, setTitle] = createSignal("");
const [error, setError] = createSignal("");
const isProtocolValid = createMemo(() => protocol() !== "");
const isCommandNumberValid = createMemo(
() => commandNumber() !== "" && !isNaN(commandNumber())
);
const isDeviceValid = createMemo(() => device() !== "" && !isNaN(device()));
const isCommandTypeValid = createMemo(() => commandType() !== "");
const isTitleValid = createMemo(
() => title() === "" || title().length >= MIN_TITLE_LENGTH
);
createEffect(() => {
setProtocol(command().getProtocol());
setCommandNumber(command().getCommandNumber());
setDevice(command().getDevice());
setCommandType(command().getCommandType());
setTitle(command().getTitle());
});
const isFormValid = createMemo(
() =>
isProtocolValid() &&
isCommandNumberValid() &&
isDeviceValid() &&
isCommandTypeValid() &&
isTitleValid()
);
async function handleEditCommand() {
try {
await RemotesService.updateCommand(
new Command({
id: command().getId(),
protocol: protocol(),
commandNumber: parseInt(commandNumber()),
device: parseInt(device()),
commandType: commandType(),
title: title(),
})
);
} catch (e) {
console.error(e);
setError(e.message);
return;
}
resetFields();
EditCommandModal.Handler.hide();
eventEmitter.dispatchEvent(COMMAND_EDITED_EVENT);
}
function resetFields() {
setProtocol("");
setCommandNumber("");
setDevice("");
setCommandType("");
setTitle("");
setError("");
}
return (
<Modal
ref={props.ref}
id="editCommandModal"
modalTitle="Edit Command"
centered={true}
>
<div class="modal-body" style="overflow-y:inherit !important;">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="edit_command_protocol" class="col-form-label col-sm-3">
Protocol
</label>
<div class="col-sm-9">
<select
class="form-select"
onChange={(e) => setProtocol(e.target.value)}
>
{Object.values(Command.PROTOCOLS).map((protocol) => (
<option
value={protocol}
selected={protocol === command().getProtocol()}
>
{Command.getProtocolString(protocol)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
<label for="edit_command_number" class="col-form-label col-sm-3">
Command Number
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_number"
valid={isCommandNumberValid()}
value={commandNumber()}
onInput={(e) => setCommandNumber(e.target.value)}
errorText={"Command number must be a number"}
/>
</div>
<div class="mb-3 row">
<label
for="edit_command_device_number"
class="col-form-label col-sm-3"
>
Device Number
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_device_number"
valid={isDeviceValid()}
value={device()}
onInput={(e) => setDevice(e.target.value)}
errorText={"Device number must be a number"}
/>
</div>
<div class="mb-3 row">
<label for="edit_command_protocol" class="col-form-label col-sm-3">
Command Type
</label>
<div class="col-sm-9">
<select
class="form-select"
onChange={(e) => setCommandType(e.target.value)}
>
{Object.values(Command.TYPES).map((commandType) => (
<option
value={commandType}
selected={commandType === command().getCommandType()}
>
{Command.getTypeString(commandType)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
<label for="edit_command_title" class="col-form-label col-sm-3">
Title
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleEditCommand}
class="btn btn-primary"
disabled={!isFormValid()}
>
Edit
</button>
</div>
</Modal>
);
}
EditCommandModal.Handler = new ModalHandler();
EditCommandModal.onCommandEdited = (callback) =>
eventEmitter.on(COMMAND_EDITED_EVENT, callback);
EditCommandModal.setCommand = setCommand;
export default EditCommandModal;

View File

@ -0,0 +1,145 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
} from "solid-js";
import ListManager from "../components/list-manager.jsx";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import Remote from "../data/remote.js";
import RemoteService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const eventEmitter = new EventEmitter();
const REMOTE_EDITED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const modalHandler = new ModalHandler();
const [remoteId, setRemoteId] = createSignal(null);
function EditRemoteModal(props) {
const [title, setTitle] = createSignal("");
const [commands, setCommands] = createSignal([]);
const [availableCommands, { refetch: refetchAvailableCommands }] =
createResource(RemoteService.getCommands);
const [remote, {}] = createResource(
remoteId,
() => RemoteService.getRemote(remoteId()),
{ initialValue: new Remote() }
);
const [error, setError] = createSignal("");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
const isFormValid = createMemo(() => isTitleValid());
createEffect(() => {
if (!remote()) return;
setTitle(remote().getTitle());
setCommands(remote().getCommands());
});
modalHandler.onShow(() => {
refetchAvailableCommands();
});
async function handleEditRemote() {
try {
await RemoteService.updateRemote(
new Remote({
id: remote().getId(),
title: title(),
commands: commands(),
})
);
} catch (e) {
setError(e.message);
console.error(e);
return;
}
resetFields();
EditRemoteModal.Handler.hide();
eventEmitter.dispatchEvent(REMOTE_EDITED_EVENT);
}
function resetFields() {
setTitle("");
setCommands([]);
setRemoteId("");
setError("");
}
function handleCommandSelect(item) {
setCommands([...commands(), item]);
}
function handleCommandDeselect(item) {
setCommands(
commands().filter((command) => command.getId() !== item.getId())
);
}
return (
<Modal
ref={props.ref}
id="editRemoteModal"
modalTitle="New Remote"
centered={true}
>
<div class="modal-body bg-body-tertiary">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="edit_remote_title" class="col-form-label col-sm-1">
Title
</label>
<ValidatedTextInput
class="col-sm-11"
id="edit_remote_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
<ListManager
style="height: 20em;"
items={commands()}
availableItems={availableCommands()}
itemToString={(command) =>
`${command.getTitle()} (${command.getProtocol()}:${command.getCommandType()})`
}
onItemSelect={handleCommandSelect}
onItemDeselect={handleCommandDeselect}
itemsTitle="Selected Commands"
availableItemsTitle="Available Commands"
itemsEqual={(a, b) => a.getId() === b.getId()}
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleEditRemote}
class="btn btn-primary"
disabled={!isFormValid()}
>
Edit
</button>
</div>
</Modal>
);
}
EditRemoteModal.Handler = modalHandler;
EditRemoteModal.onRemoteEdited = (callback) =>
eventEmitter.on(REMOTE_EDITED_EVENT, callback);
EditRemoteModal.setRemoteId = setRemoteId;
export default EditRemoteModal;

View File

@ -0,0 +1,500 @@
import {
createEffect,
createMemo,
createSignal,
Match,
on,
Show,
Switch,
} from "solid-js";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import Command from "../data/command.js";
const eventEmitter = new EventEmitter();
const COMMANDS_IMPORTED_EVENT = "success";
const ENTER_CSV_STEP = 1;
const MAP_FIELDS_STEP = 2;
const MAP_VALUES_STEP = 3;
const CONFIRM_STEP = 4;
const TOTAL_STEPS = 4;
const PROTOCOL_FIELD = "protocol";
const COMMAND_NUMBER_FIELD = "commandNumber";
const DEVICE_FIELD = "device";
const COMMAND_TYPE_FIELD = "commandType";
const TITLE_FIELD = "title";
let FieldTitles = {};
FieldTitles[PROTOCOL_FIELD] = "Protocol";
FieldTitles[COMMAND_NUMBER_FIELD] = "Command";
FieldTitles[DEVICE_FIELD] = "Device";
FieldTitles[COMMAND_TYPE_FIELD] = "Type";
FieldTitles[TITLE_FIELD] = "Title";
const CommandFieldsRequiringValueMapping = ["protocol", "commandType"];
const commandTypeFuse = new Fuse(
Object.values(Command.TYPES).map((type) => Command.getTypeString(type))
);
const protocolsFuse = new Fuse(
Object.values(Command.PROTOCOLS).map((protocol) =>
Command.getProtocolString(protocol)
)
);
function ImportCommandsModal(props) {
const [csvString, setCsvString] = createSignal("");
const [currentStep, setCurrentStep] = createSignal(ENTER_CSV_STEP);
const [error, setError] = createSignal("");
const [csvArray, setCsvArray] = createSignal([]);
const [commands, setCommands] = createSignal([]);
const [fieldMapping, setFieldMapping] = createSignal(
{},
{ equals: () => false }
);
const [isComputingValueMapping, setComputingValueMapping] =
createSignal(false);
const [valueMapping, setValueMapping] = createSignal(
(() => {
let mapping = {};
mapping[PROTOCOL_FIELD] = {};
mapping[COMMAND_TYPE_FIELD] = {};
return mapping;
})(),
{ equals: () => false }
);
const canMakeNextStep = createMemo(() => {
switch (currentStep()) {
case ENTER_CSV_STEP:
return csvString() !== "";
default:
return true;
}
});
const fields = createMemo(() =>
Object.keys(csvArray().find(() => true) || {})
);
createEffect(() => {
switch (currentStep()) {
case MAP_VALUES_STEP:
onMapValuesStep();
break;
}
});
function handleNextStep() {
switch (currentStep()) {
case ENTER_CSV_STEP:
handleEnterCsvStep();
break;
case MAP_FIELDS_STEP:
handleMapFieldsStep();
break;
case MAP_VALUES_STEP:
handleMapValuesStep();
break;
case CONFIRM_STEP:
handleImportCommands();
break;
default:
nextStep();
}
}
async function handleImportCommands() {
let newCommands = [];
try {
newCommands = await RemotesService.createCommands(commands());
} catch (e) {
setError(e.message);
return;
}
resetFields();
ImportCommandsModal.Handler.hide();
eventEmitter.dispatchEvent(COMMANDS_IMPORTED_EVENT, newCommands);
}
function EnterCsvStep() {
return (
<div class="mb-3">
<label for="csvTextarea" class="form-label">
Enter CSV:
</label>
<textarea
class="form-control"
id="csvTextarea"
rows="10"
value={csvString()}
onInput={(e) => setCsvString(e.target.value)}
></textarea>
</div>
);
}
function handleEnterCsvStep() {
if (!csvString()) {
setError("Please enter a CSV string.");
return;
}
let result = Papa.parse(csvString(), { header: true });
if (result.errors.length > 0) {
setError(result.errors[0].message);
return;
}
let csvArray = result.data;
setCsvArray(csvArray);
nextStep();
}
function MapFieldsStep() {
function setMapping(field, commandField) {
let mapping = fieldMapping();
mapping[field] = commandField;
setFieldMapping(mapping);
}
return (
<div class="mb-3">
<div>Map Fields:</div>
<div class="d-flex flex-column">
{fields().map((field) => (
<div class="d-flex flex-row align-items-center justify-content-center text-end mb-2">
<div class="col-sm-4">
<div>{field}</div>
<div class="fw-light lh-1">(e.g. {csvArray()[0][field]})</div>
</div>
<div style="font-size: 1.5rem; line-height: 1;">
<i class="bi bi-arrow-right-short"></i>
</div>
<div class="col-sm-4">
<select
class="form-select"
onChange={(e) => setMapping(field, e.target.value)}
>
<option value="" selected>
Please select
</option>
{Object.keys(FieldTitles).map((commandField) => (
<option
value={commandField}
selected={fieldMapping()[field] === commandField}
>
{FieldTitles[commandField]}
</option>
))}
</select>
</div>
</div>
))}
</div>
</div>
);
}
function handleMapFieldsStep() {
let usedCommandFields = [];
let mapping = fieldMapping();
for (let field in mapping) {
if (usedCommandFields.includes(mapping[field])) {
setError("Duplicate mapping found.");
return false;
}
usedCommandFields.push(mapping[field]);
}
nextStep();
}
function MapValuesStep() {
function setMapping(field, value, commandValue) {
let mapping = valueMapping();
if (!mapping[field]) mapping[field] = {};
mapping[field][value] = commandValue;
setValueMapping(mapping);
}
return (
<div class="mb-3">
<div>Map Values:</div>
{Object.keys(fieldMapping())
.filter((field) =>
CommandFieldsRequiringValueMapping.includes(fieldMapping()[field])
)
.map((csvField) => {
let field = fieldMapping()[csvField];
return (
<div>
<div>{FieldTitles[field]}:</div>
<div class="d-flex flex-column">
{csvArray()
.map((row) => row[csvField])
.filter(
(value, index, array) => array.indexOf(value) === index
)
.map((value) => (
<div class="d-flex flex-row align-items-center justify-content-center text-end mb-2">
<div class="col-sm-4">
<div>{value}</div>
</div>
<div style="font-size: 1.5rem; line-height: 1;">
<i class="bi bi-arrow-right-short"></i>
</div>
<div class="col-sm-4">
<Switch>
<Match when={field === "protocol"}>
<select
class="form-select"
onChange={(e) =>
setMapping(field, value, e.target.value)
}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.PROTOCOLS).map(
(protocol) => (
<option
value={protocol}
selected={
valueMapping()[PROTOCOL_FIELD][
value
] === protocol
}
>
{Command.getProtocolString(protocol)}
</option>
)
)}
</select>
</Match>
<Match when={field === "commandType"}>
<select
class="form-select"
onChange={(e) =>
setMapping(field, value, e.target.value)
}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.TYPES).map(
(commandType) => (
<option
value={commandType}
selected={
valueMapping()[COMMAND_TYPE_FIELD][
value
] === commandType
}
>
{Command.getTypeString(commandType)}
</option>
)
)}
</select>
</Match>
</Switch>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
function onMapValuesStep() {
setComputingValueMapping(true);
(async () => {
let valueMapping = {};
valueMapping[PROTOCOL_FIELD] = {};
valueMapping[COMMAND_TYPE_FIELD] = {};
let protocolField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === PROTOCOL_FIELD
);
let commandTypeField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_TYPE_FIELD
);
csvArray().forEach((row) => {
let protocolValue = row[protocolField];
let commandTypeValue = row[commandTypeField];
if (!valueMapping[PROTOCOL_FIELD][protocolValue]) {
let result = protocolsFuse.search(protocolValue).shift();
if (result) {
let protocol = Object.values(Command.PROTOCOLS).find(
(protocol) => Command.getProtocolString(protocol) === result.item
);
valueMapping[PROTOCOL_FIELD][protocolValue] = protocol;
}
}
if (!valueMapping[COMMAND_TYPE_FIELD][commandTypeValue]) {
let result = commandTypeFuse.search(commandTypeValue).shift();
if (result) {
let commandType = Object.values(Command.TYPES).find(
(commandType) =>
Command.getTypeString(commandType) === result.item
);
valueMapping[COMMAND_TYPE_FIELD][commandTypeValue] = commandType;
}
}
});
setValueMapping(valueMapping);
setComputingValueMapping(false);
})();
}
function handleMapValuesStep() {
let protocolField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === PROTOCOL_FIELD
);
let commandNumberField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_NUMBER_FIELD
);
let deviceField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === DEVICE_FIELD
);
let commandTypeField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_TYPE_FIELD
);
let titleField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === TITLE_FIELD
);
let commands = csvArray()
.map((row) => {
let protocol = valueMapping()[PROTOCOL_FIELD][row[protocolField]];
if (!protocol) return null;
let commandNumber = row[commandNumberField];
if (isNaN(commandNumber)) return null;
commandNumber = parseInt(commandNumber);
let device = row[deviceField];
if (isNaN(device)) return null;
device = parseInt(device);
let commandType =
valueMapping()[COMMAND_TYPE_FIELD][row[commandTypeField]];
if (!commandType) return null;
let title = row[titleField];
let command = new Command({
protocol,
commandNumber,
device,
commandType,
title,
});
return command;
})
.filter((command) => command !== null);
setCommands(commands);
nextStep();
}
function ConfirmStep() {
return (
<div class="mb-3">
<div>Confirm:</div>
<div class="d-flex flex-column">
{commands().map((command) => (
<div class="d-flex flex-row align-items-center justify-content-center mb-2">
<div class="col-sm-2">{command.getProtocol()}</div>
<div class="col-sm-2">{command.getCommandNumber()}</div>
<div class="col-sm-2">{command.getDevice()}</div>
<div class="col-sm-2">{command.getCommandType()}</div>
<div class="col-sm-2">{command.getTitle()}</div>
</div>
))}
</div>
</div>
);
}
function nextStep() {
if (currentStep() >= TOTAL_STEPS) return;
setError("");
setCurrentStep(currentStep() + 1);
}
function previousStep() {
if (currentStep() <= 1) return;
setError("");
setCurrentStep(currentStep() - 1);
}
function resetFields() {
setCsvString("");
setError("");
}
return (
<Modal
ref={props.ref}
id="importCommandsModal"
modalTitle="Import Commands from CSV"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<Switch>
<Match when={currentStep() === ENTER_CSV_STEP}>
<EnterCsvStep />
</Match>
<Match when={currentStep() === MAP_FIELDS_STEP}>
<MapFieldsStep />
</Match>
<Match when={currentStep() === MAP_VALUES_STEP}>
<MapValuesStep />
</Match>
<Match when={currentStep() === CONFIRM_STEP}>
<ConfirmStep />
</Match>
</Switch>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<Show when={currentStep() > 1}>
<button
type="button"
onClick={previousStep}
class="btn btn-secondary"
disabled={false}
>
Back
</button>
</Show>
<Show when={currentStep() < TOTAL_STEPS}>
<button
type="button"
onClick={handleNextStep}
class="btn btn-secondary"
disabled={!canMakeNextStep()}
>
Next
</button>
</Show>
<Show when={currentStep() === TOTAL_STEPS}>
<button
type="button"
onClick={handleImportCommands}
class="btn btn-primary"
disabled={false}
>
Import
</button>
</Show>
</div>
</Modal>
);
}
ImportCommandsModal.Handler = new ModalHandler();
ImportCommandsModal.onCommandsImported = (callback) =>
eventEmitter.on(COMMANDS_IMPORTED_EVENT, callback);
export default ImportCommandsModal;

View File

@ -1,11 +1,23 @@
import EventEmitter from "../tools/event-emitter";
const SHOW_EVENT = "show";
const HIDE_EVENT = "hide";
function ModalHandler() {
let _ref;
let _modalRef;
let _modalId;
let eventEmitter = new EventEmitter();
function setRef(ref) {
_ref = ref;
_modalRef = new bootstrap.Modal(ref);
_ref.addEventListener('hidden.bs.modal', () => {
eventEmitter.dispatchEvent(HIDE_EVENT);
});
_ref.addEventListener('show.bs.modal', () => {
eventEmitter.dispatchEvent(SHOW_EVENT);
});
}
function show() {
@ -21,15 +33,11 @@ function ModalHandler() {
}
function onHidden(callback) {
_ref.addEventListener('hidden.bs.modal', () => {
callback();
});
eventEmitter.on(HIDE_EVENT, callback);
}
function onShow(callback) {
_ref.addEventListener('show.bs.modal', () => {
callback();
});
eventEmitter.on(SHOW_EVENT, callback);
}
return {

View File

@ -6,6 +6,13 @@ import UserSettingsModal from "./user-settings-modal.jsx";
import CreateDeviceModal from "./create-device-modal.jsx";
import ShowRegistrationCodeModal from "./show-registration-code-modal.jsx";
import DeleteIntegrationModal from "./delete-integration-modal.jsx";
import CreateCommandModal from "./create-command-modal.jsx";
import DeleteCommandModal from "./delete-command-modal.jsx";
import CreateRemoteModal from "./create-remote-modal.jsx";
import DeleteRemoteModal from "./delete-remote-modal.jsx";
import ImportCommandsModal from "./import-commands-modal.jsx";
import EditCommandModal from "./edit-command-modal.jsx";
import EditRemoteModal from "./edit-remote-modal.jsx";
const ModalRegistry = (function () {
const modals = [
@ -39,6 +46,41 @@ const ModalRegistry = (function () {
component: DeleteIntegrationModal,
ref: null,
},
{
id: "newCommandModal",
component: CreateCommandModal,
ref: null,
},
{
id: "deleteCommandModal",
component: DeleteCommandModal,
ref: null,
},
{
id: "createRemoteModal",
component: CreateRemoteModal,
ref: null,
},
{
id: "deleteRemoteModal",
component: DeleteRemoteModal,
ref: null,
},
{
id: "importCommandsModal",
component: ImportCommandsModal,
ref: null,
},
{
id: "editCommandModal",
component: EditCommandModal,
ref: null,
},
{
id: "editRemoteModal",
component: EditRemoteModal,
ref: null,
}
];
function getModals(props) {

View File

@ -7,7 +7,7 @@ import {
import EventEmitter from "../tools/event-emitter.js";
import User from "../data/user.js";
import Modal from "./modal.jsx";
import ValidatedTextInput from "../modules/validated-text-input.jsx";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import ModalHandler from "./modal-handler.js";
import UserService from "../services/user-service.js";

View File

@ -66,8 +66,8 @@ function DeviceService() {
async function getRegistrationCode() {
let response = await Net.sendRequest({
method: "GET",
url: "/api/integrations/register",
method: "POST",
url: "/api/integrations",
});
if (response.status !== 200) {
@ -79,6 +79,23 @@ function DeviceService() {
return codeObject.code;
}
async function getIntegration(id) {
if (!id) return null;
let response = await Net.sendRequest({
method: "GET",
url: "/api/integrations/" + id,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let integration = JSON.parse(response.data);
integration = Serializer.deserializeIntegration(integration);
return integration;
}
async function getIntegrations() {
let response = await Net.sendRequest({
method: "GET",
@ -113,6 +130,7 @@ function DeviceService() {
updateDevice,
deleteDevice,
getRegistrationCode,
getIntegration,
getIntegrations,
deleteIntegration,
};

View File

@ -0,0 +1,163 @@
import Serializer from "../data/serializer";
import Net from "../tools/net";
import WebRTCService from "./webrtc-service";
const MESSAGE_TYPE_COMMAND = "command";
function RemoteService() {
async function getRemote(remoteId) {
if (!remoteId) return null;
let response = await Net.sendRequest({
method: "GET",
url: "/api/remotes/" + remoteId,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let remoteObject = JSON.parse(response.data);
return Serializer.deserializeRemote(remoteObject);
}
async function getRemotes() {
let response = await Net.sendRequest({
method: "GET",
url: "/api/remotes",
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let remoteObjects = JSON.parse(response.data);
return Serializer.deserializeRemotes(remoteObjects);
}
async function createRemote(remote) {
let remoteObject = Serializer.serializeRemote(remote);
let response = await Net.sendJsonRequest({
method: "POST",
url: "/api/remotes",
data: remoteObject,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function updateRemote(remote) {
let remoteObject = Serializer.serializeRemote(remote);
let response = await Net.sendJsonRequest({
method: "PUT",
url: "/api/remotes/" + remote.getId(),
data: remoteObject,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function deleteRemote(remoteId) {
let response = await Net.sendRequest({
method: "DELETE",
url: "/api/remotes/" + remoteId,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function getCommands() {
let response = await Net.sendRequest({
method: "GET",
url: "/api/commands",
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let commandObjects = JSON.parse(response.data);
return Serializer.deserializeCommands(commandObjects);
}
async function createCommand(command) {
return createCommands([command]);
}
async function createCommands(commands) {
let commandObjects = Serializer.serializeCommands(commands);
let response = await Net.sendJsonRequest({
method: "POST",
url: "/api/commands",
data: commandObjects,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function updateCommand(command) {
let commandObject = Serializer.serializeCommand(command);
let response = await Net.sendJsonRequest({
method: "PUT",
url: "/api/commands/" + command.getId(),
data: commandObject,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function deleteCommand(commandId) {
let response = await Net.sendRequest({
method: "DELETE",
url: "/api/commands/" + commandId,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
function sendCommand(command) {
let commandObject = Serializer.serializeCommand(command);
WebRTCService.sendDataJson({
type: MESSAGE_TYPE_COMMAND,
data: commandObject,
});
}
return {
getRemote,
getRemotes,
createRemote,
updateRemote,
deleteRemote,
getCommands,
createCommand,
createCommands,
updateCommand,
deleteCommand,
sendCommand,
};
}
RemoteService = new RemoteService();
export default RemoteService;

View File

@ -0,0 +1,191 @@
import EventEmitter from "../tools/event-emitter";
import WebsocketService from "./websocket-service";
function WebRTCService() {
const TYPE_SIGNALING = "signaling";
const STATE_CONNECTED = "connected";
const STATE_DISCONNECTED = "disconnected";
const STATE_CLOSED = "closed";
const STATE_FAILED = "failed";
const ICE_CONNECTION_STATE_CHANGE_EVENT = "iceconnectionstatechange";
const DATA_CHANNEL_OPEN_EVENT = "datachannelopen";
let videoElement;
let peerConnection;
let peerId;
let dataChannel;
let eventEmitter = new EventEmitter();
WebsocketService.onMessage((data) => {
let dataObject = JSON.parse(data);
let sender = dataObject.sender;
if (sender !== peerId) return;
let message = dataObject.message;
handleSignalingMessage(peerId, message);
});
async function connect(targetId) {
peerId = targetId;
let configuration = getConfiguration();
peerConnection = new RTCPeerConnection(configuration);
console.log("ICE connection state:" + peerConnection.iceConnectionState);
peerConnection.addEventListener("iceconnectionstatechange", (event) => {
let state = peerConnection.iceConnectionState;
console.log("ICE connection state changed to:", state);
eventEmitter.dispatchEvent(ICE_CONNECTION_STATE_CHANGE_EVENT, state);
if (state === STATE_CONNECTED) {
videoElement.play();
}
});
peerConnection.addEventListener("signalingstatechange", (event) => {
console.log("Signaling state changed to:", event.target.signalingState);
});
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
sendIceCandidate(targetId, event.candidate);
}
};
peerConnection.ontrack = (event) => {
console.log("Received track:", event.track);
if (videoElement) {
videoElement.srcObject.addTrack(event.track);
}
};
peerConnection.onicecandidateerror = (event) => {
console.error("ICE candidate error:", event);
};
peerConnection.onnegotiationneeded = () => {
console.log("Negotiation needed");
negotiate(targetId);
};
dataChannel = peerConnection.createDataChannel("data");
dataChannel.addEventListener("open", () => {
eventEmitter.dispatchEvent(DATA_CHANNEL_OPEN_EVENT);
});
negotiate(targetId);
}
function disconnect() {
peerConnection.close();
eventEmitter.dispatchEvent(
ICE_CONNECTION_STATE_CHANGE_EVENT,
peerConnection.iceConnectionState
);
}
async function negotiate(targetId) {
try {
let offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
console.log("Created offer:", offer);
await peerConnection.setLocalDescription(offer);
sendOffer(targetId, offer);
} catch (error) {
console.error("Error creating offer:", error);
}
}
function sendOffer(integrationId, offer) {
WebsocketService.sendJson({
type: TYPE_SIGNALING,
target: integrationId,
message: offer,
});
}
function sendIceCandidate(integrationId, candidate) {
console.log("Sending ICE candidate:", candidate);
WebsocketService.sendJson({
type: TYPE_SIGNALING,
target: integrationId,
message: {
type: "ice_candidate",
candidate: candidate,
},
});
}
function handleSignalingMessage(targetId, message) {
//console.log("Received message:", message);
switch (message.type) {
case "answer":
handleAnswer(message);
break;
case "ice_candidate":
handleIceCandidate(message);
break;
default:
return;
}
}
function handleAnswer(answer) {
console.log("Remote answer:", answer);
if (peerConnection.signalingState === "stable") return;
peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
}
function handleIceCandidate(message) {
let iceCandidate = new RTCIceCandidate(message.candidate);
console.log("Received ICE candidate:", iceCandidate);
peerConnection.addIceCandidate(iceCandidate);
}
function sendDataString(data) {
if (!dataChannel) return;
if (dataChannel.readyState !== "open") return;
dataChannel.send(data);
}
function sendDataJson(data) {
let dataJson = JSON.stringify(data);
sendDataString(dataJson);
}
function onStateChanged(callback) {
eventEmitter.on(ICE_CONNECTION_STATE_CHANGE_EVENT, () => {
callback(peerConnection.iceConnectionState);
});
}
function onDataChannelOpen(callback) {
eventEmitter.on(DATA_CHANNEL_OPEN_EVENT, callback);
}
function getConfiguration() {
return {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
};
}
function setVideoElement(element) {
videoElement = element;
videoElement.srcObject = new MediaStream();
}
function getVideoElement() {
return videoElement;
}
return {
STATE_CONNECTED,
STATE_DISCONNECTED,
STATE_CLOSED,
STATE_FAILED,
connect,
disconnect,
setVideoElement,
getVideoElement,
sendDataString,
sendDataJson,
onStateChanged,
onDataChannelOpen,
};
}
WebRTCService = new WebRTCService();
export default WebRTCService;

View File

@ -0,0 +1,40 @@
function WebsocketService() {
let websocket;
initialize();
function initialize() {
let url = "ws://" + location.host + "/ws";
websocket = new WebSocket(url);
}
function onMessage(callback) {
if (!websocket) throw new Error("Websocket is not open");
websocket.addEventListener("message", event => {
callback(event.data);
});
}
function sendMessage(message) {
if (!isWebsocketOpen()) throw new Error("Websocket is not open");
websocket.send(message);
}
function sendJson(jsonObject) {
let jsonString = JSON.stringify(jsonObject);
sendMessage(jsonString);
}
function isWebsocketOpen() {
return websocket.readyState === WebSocket.OPEN;
}
return {
onMessage,
sendJson,
sendMessage,
};
}
WebsocketService = new WebsocketService();
export default WebsocketService;

View File

@ -0,0 +1,30 @@
function FileUtils() {
function createJsonFile(jsonData, fileName) {
const json = JSON.stringify(jsonData, null, 2);
const file = new File([json], fileName, {
type: "text/json;charset=utf-8",
});
return file;
}
function downloadFile(file) {
console.log(file)
const url = URL.createObjectURL(file);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", file.name);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
return {
createJsonFile,
downloadFile,
};
}
FileUtils = new FileUtils();
export default FileUtils;

View File

@ -1,5 +1,5 @@
import { createSignal, onMount } from "solid-js";
import Spinner from "../modules/spinner";
import Spinner from "../components/spinner";
import MainView from "./main-view";
import LoginView from "./login-view";
import UserService from "../services/user-service";

View File

@ -1,192 +0,0 @@
import {
createResource,
createSignal,
mergeProps,
onMount,
Show,
} from "solid-js";
import List from "../modules/list";
import CreateDeviceModal from "../modals/create-device-modal";
import DeviceService from "../services/device-service";
import ShowRegistrationCodeModal from "../modals/show-registration-code-modal";
import DeleteIntegrationModal from "../modals/delete-integration-modal";
function DevicesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props);
const DEVICES_LIST_TYPE = "devices";
const INTEGRATION_LIST_TYPE = "integrations";
const [listType, setListType] = createSignal(INTEGRATION_LIST_TYPE);
const [devices, setDevices] = createSignal([]);
const [integrations, { refetch: refetchIntegrations }] = createResource(
DeviceService.getIntegrations
);
CreateDeviceModal.onDeviceCreated(() => {
handleRefreshDevices();
});
onMount(() => {
handleRefreshDevices();
DeleteIntegrationModal.onIntegrationDeleted(() => {
refetchIntegrations();
});
});
function handleNewDevice() {
CreateDeviceModal.Handler.show();
}
async function handleRefreshDevices() {
let devices = await DeviceService.getDevices();
setDevices(devices);
}
function handleRegisterIntegration() {
ShowRegistrationCodeModal.Handler.show();
}
function handleDeleteIntegration(integration) {
DeleteIntegrationModal.setIntegration(integration);
DeleteIntegrationModal.Handler.show();
}
function handleIntegrationItemClicked(item) {
props.onIntegrationClicked(item.integration);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
<div class="d-flex flex-row flex-fill">
<button
class={
"btn me-2 mb-3" +
(listType() === INTEGRATION_LIST_TYPE
? " btn-secondary"
: " btn-dark")
}
onClick={() => setListType(INTEGRATION_LIST_TYPE)}
>
<i class="bi bi-gear me-2"></i>
Integrations
</button>
<button
class={
"btn me-2 mb-3" +
(listType() === DEVICES_LIST_TYPE
? " btn-secondary"
: " btn-dark")
}
onClick={() => setListType(DEVICES_LIST_TYPE)}
>
<i class="bi bi-tv me-2"></i>
Devices
</button>
</div>
<div class="d-flex flex-row justify-content-end flex-fill">
<Show when={listType() === DEVICES_LIST_TYPE}>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewDevice}>
<i class="bi bi-plus-square me-2"></i>
New Device
</button>
</Show>
<Show when={listType() === INTEGRATION_LIST_TYPE}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleRegisterIntegration}
>
<i class="bi bi-plus-square me-2"></i>
Register
</button>
</Show>
</div>
</div>
<Show when={listType() === DEVICES_LIST_TYPE}>
<List
onListItemClick={() => {}}
items={devices().map((device) => ({
id: {
html: <span class="font-monospace">{device.getId()}</span>,
},
name: {
text: device.getName(),
},
description: {
text: device.getDescription(),
},
device,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
width: 10,
},
{
id: "description",
name: "Description",
},
]}
></List>
</Show>
<Show when={listType() === INTEGRATION_LIST_TYPE}>
<List
onListItemClick={handleIntegrationItemClicked}
items={(integrations() || []).map((integration) => ({
id: {
html: <span class="font-monospace">{integration.getId()}</span>,
},
name: {
text: integration.getName(),
},
options: {
html: (
<>
<button
class="btn btn-outline-secondary me-2"
onClick={() => handleDeleteIntegration(integration)}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
integration: integration,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
},
{
id: "options",
name: "",
width: 4,
},
]}
></List>
</Show>
</div>
);
}
export default DevicesView;

View File

@ -0,0 +1,76 @@
import { createSignal } from "solid-js";
import List from "../../components/list";
import CreateDeviceModal from "../../modals/create-device-modal";
import DeviceService from "../../services/device-service";
function DevicesListView(props) {
const [devices, setDevices] = createSignal([]);
handleRefreshDevices();
CreateDeviceModal.onDeviceCreated(() => {
handleRefreshDevices();
});
function handleNewDevice() {
CreateDeviceModal.Handler.show();
}
async function handleRefreshDevices() {
let devices = await DeviceService.getDevices();
setDevices(devices);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
{props.navigation}
<div class="d-flex flex-row justify-content-end flex-fill">
<button class="btn btn-dark me-2 mb-3" onClick={handleNewDevice}>
<i class="bi bi-plus-square me-2"></i>
New Device
</button>
</div>
</div>
<List
onListItemClick={() => {}}
items={devices().map((device) => ({
id: {
html: <span class="font-monospace">{device.getId()}</span>,
},
name: {
text: device.getName(),
},
description: {
text: device.getDescription(),
},
device,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
width: 10,
},
{
id: "description",
name: "Description",
},
]}
></List>
</div>
);
}
export default DevicesListView;

View File

@ -0,0 +1,105 @@
import {
createEffect,
createSignal,
Match,
mergeProps,
onCleanup,
Switch,
} from "solid-js";
import DevicesListView from "./devices-list-view";
import IntegrationListView from "./integration-list-view";
import UrlUtils from "../../tools/url-utils";
import IntegrationView from "./integration-view";
function DevicesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props);
const DEVICES_LIST_VIEW = "devices";
const INTEGRATION_LIST_VIEW = "integrations";
const INTEGRATION_VIEW = "integration";
const VIEWS = [DEVICES_LIST_VIEW, INTEGRATION_LIST_VIEW, INTEGRATION_VIEW];
const [currentView, setCurrentView] = createSignal(INTEGRATION_LIST_VIEW);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "tab", currentView());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setViewFromUrl);
});
setViewFromUrl();
window.addEventListener("popstate", setViewFromUrl);
function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("tab");
if (!view) return;
if (!VIEWS.includes(view)) return;
setCurrentView(view);
}
function handleIntegrationClicked(integration) {
IntegrationView.setIntegration(integration);
setCurrentView(INTEGRATION_VIEW);
}
function Navigation() {
return (
<div class="d-flex flex-row flex-fill">
<button
class={
"btn me-2 mb-3" +
(currentView() === INTEGRATION_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(INTEGRATION_LIST_VIEW)}
>
<i class="bi bi-gear me-2"></i>
Integrations
</button>
<button
class={
"btn me-2 mb-3" +
(currentView() === DEVICES_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(DEVICES_LIST_VIEW)}
>
<i class="bi bi-tv me-2"></i>
Devices
</button>
</div>
);
}
return (
<div
class={
"d-flex flex-column overflow-hidden " +
props.class
}
>
<Switch>
<Match when={currentView() === INTEGRATION_LIST_VIEW}>
<IntegrationListView
navigation={<Navigation />}
onIntegrationClicked={handleIntegrationClicked}
/>
</Match>
<Match when={currentView() === DEVICES_LIST_VIEW}>
<DevicesListView navigation={<Navigation />} />
</Match>
<Match when={currentView() === INTEGRATION_VIEW}>
<IntegrationView />
</Match>
</Switch>
</div>
);
}
export default DevicesView;

View File

@ -0,0 +1,103 @@
import { createResource, mergeProps } from "solid-js";
import List from "../../components/list";
import ShowRegistrationCodeModal from "../../modals/show-registration-code-modal";
import DeleteIntegrationModal from "../../modals/delete-integration-modal";
import DeviceService from "../../services/device-service";
function IntegrationListView(props) {
props = mergeProps(
{
onIntegrationClicked: () => {},
},
props
);
const [integrations, { refetch: refetchIntegrations }] = createResource(
DeviceService.getIntegrations,
{ initialValue: [] }
);
function handleRegisterIntegration() {
ShowRegistrationCodeModal.Handler.show();
}
DeleteIntegrationModal.onIntegrationDeleted(() => {
refetchIntegrations();
});
function handleDeleteIntegration(integration) {
DeleteIntegrationModal.setIntegration(integration);
DeleteIntegrationModal.Handler.show();
}
function handleIntegrationItemClicked(item) {
props.onIntegrationClicked(item.integration);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
{props.navigation}
<div class="d-flex flex-row justify-content-end flex-fill">
<button
class="btn btn-dark me-2 mb-3"
onClick={handleRegisterIntegration}
>
<i class="bi bi-plus-square me-2"></i>
Register
</button>
</div>
</div>
<List
onListItemClick={handleIntegrationItemClicked}
items={integrations().map((integration) => ({
id: {
html: <span class="font-monospace">{integration.getId()}</span>,
},
name: {
text: integration.getName(),
},
options: {
html: (
<>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleDeleteIntegration(integration);
}}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
integration: integration,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
},
{
id: "options",
name: "",
width: 4,
},
]}
></List>
</div>
);
}
export default IntegrationListView;

View File

@ -0,0 +1,181 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
Show,
} from "solid-js";
import WebRTCService from "../../services/webrtc-service";
import Integration from "../../data/integration";
import DeviceService from "../../services/device-service";
import UrlUtils from "../../tools/url-utils";
import RemoteControl from "../../components/remote-control";
import RemoteService from "../../services/remotes-service";
const [integration, setIntegration] = createSignal(new Integration());
function IntegrationView(props) {
const title = createMemo(() =>
integration && typeof integration === "function"
? integration().getName()
: "Integration"
);
const [connectionState, setConnectionState] = createSignal(
WebRTCService.STATE_DISCONNECTED
);
const showConnectButton = createMemo(
() =>
connectionState() === WebRTCService.STATE_DISCONNECTED ||
connectionState() === WebRTCService.STATE_FAILED ||
connectionState() === WebRTCService.STATE_CLOSED
);
WebRTCService.onStateChanged(handleConnectionStateChanged);
WebRTCService.onDataChannelOpen(handleDataChannelOpen);
let videoElement = null;
const [availableRemotes] = createResource(RemoteService.getRemotes, {
initialValue: [],
});
const [selectedRemote, setSelectedRemote] = createSignal();
const [remoteControlVisible, setRemoteControlVisible] = createSignal(false);
createEffect(() =>
handleRemoteSelected(
availableRemotes()
.find(() => true)
?.getId()
)
);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "id", integration()?.getId());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setIntegrationFromUrl);
});
setIntegrationFromUrl();
window.addEventListener("popstate", setIntegrationFromUrl);
async function setIntegrationFromUrl() {
let integrationId = UrlUtils.getQueryParameter("id");
if (!integrationId) return;
let integration = await DeviceService.getIntegration(integrationId);
setIntegration(integration);
}
function handleConnectWebRTC() {
let integrationId = integration().getId();
WebRTCService.setVideoElement(videoElement);
WebRTCService.connect(integrationId);
}
function handleDisconnectWebRTC() {
WebRTCService.disconnect();
}
function handleConnectionStateChanged(state) {
setConnectionState(state);
}
function handleDataChannelOpen() {
setInterval(() => {
WebRTCService.sendDataJson({ message: "ping" });
}, 1000);
}
async function handleRemoteSelected(remoteId) {
if (!remoteId) return;
let remote = await RemoteService.getRemote(remoteId);
setSelectedRemote(remote);
}
function handleRemoteButtonPressed(command) {
RemoteService.sendCommand(command);
}
function toggleRemoteControl() {
setRemoteControlVisible(!remoteControlVisible());
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<a>
<i class="bi bi-arrow-left-short"></i>
<span>Integration</span>
</a>
<h1>{title}</h1>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-end flex-fill">
<Show when={showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleConnectWebRTC}
>
<i class="bi bi-plug-fill me-2"></i>
Connect
</button>
</Show>
<Show when={!showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleDisconnectWebRTC}
>
<i class="bi bi-x-lg me-2"></i>
Disconnect
</button>
</Show>
</div>
</div>
<div class="flex-fill d-flex flex-row overflow-hidden">
<div
class="flex-fill rounded overflow-hidden"
style="position:relative;"
>
<video
ref={videoElement}
class="w-100 h-100"
style="background-color: #000"
></video>
<button
class="btn btn-dark mt-2 me-2"
style="transform: rotate(180deg);position:absolute;top:0;right:0;z-index:1000;"
onClick={toggleRemoteControl}
>
<i class="bi bi-building"></i>
</button>
</div>
<Show when={remoteControlVisible()}>
<div class="d-flex flex-column ps-3">
<select
class="form-select mb-3"
onChange={(e) => handleRemoteSelected(e.target.value)}
>
{availableRemotes().map((remote, index) => (
<option value={remote.getId()} selected={index === 0}>
{remote.getTitle()}
</option>
))}
</select>
<RemoteControl
onCommand={handleRemoteButtonPressed}
remote={selectedRemote()}
/>
</div>
</Show>
</div>
</div>
);
}
IntegrationView.setIntegration = setIntegration;
export default IntegrationView;

View File

@ -1,51 +0,0 @@
import { createMemo, createSignal } from "solid-js";
import Integration from "../data/integration";
const [integration, setIntegration] = createSignal(null);
const offer = {
type: "offer",
sdp: "v=0\r\no=- 3395492761835116674 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS 077a17e7-aec0-4aec-bef9-da6e02d3458f\r\nm=video 57551 UDP/TLS/RTP/SAVPF 96 97 103 104 107 108 109 114 115 116 117 118 39 40 45 46 98 99 100 101 119 120 121\r\nc=IN IP4 192.168.178.23\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=candidate:4029581777 1 udp 2122260223 192.168.178.23 57551 typ host generation 0 network-id 1 network-cost 10\r\na=candidate:2397137737 1 tcp 1518280447 192.168.178.23 9 typ host tcptype active generation 0 network-id 1 network-cost 10\r\na=ice-ufrag:BJUp\r\na=ice-pwd:7yhav/1dA688oD7sU/tb3TMI\r\na=ice-options:trickle\r\na=fingerprint:sha-256 DD:1D:7E:BB:A4:69:BF:B9:B1:3B:F2:58:A8:4A:EB:F0:BE:B8:06:5E:A0:BB:42:26:5E:79:3B:DD:3D:4E:44:30\r\na=setup:actpass\r\na=mid:0\r\na=extmap:1 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:3 urn:3gpp:video-orientation\r\na=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\na=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\na=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\na=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\na=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\na=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\na=sendrecv\r\na=msid:077a17e7-aec0-4aec-bef9-da6e02d3458f 593c4b1b-5b4b-4bed-b64a-2da1011de475\r\na=rtcp-mux\r\na=rtcp-rsize\r\na=rtpmap:96 VP8/90000\r\na=rtcp-fb:96 goog-remb\r\na=rtcp-fb:96 transport-cc\r\na=rtcp-fb:96 ccm fir\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=rtpmap:97 rtx/90000\r\na=fmtp:97 apt=96\r\na=rtpmap:103 H264/90000\r\na=rtcp-fb:103 goog-remb\r\na=rtcp-fb:103 transport-cc\r\na=rtcp-fb:103 ccm fir\r\na=rtcp-fb:103 nack\r\na=rtcp-fb:103 nack pli\r\na=fmtp:103 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r\na=rtpmap:104 rtx/90000\r\na=fmtp:104 apt=103\r\na=rtpmap:107 H264/90000\r\na=rtcp-fb:107 goog-remb\r\na=rtcp-fb:107 transport-cc\r\na=rtcp-fb:107 ccm fir\r\na=rtcp-fb:107 nack\r\na=rtcp-fb:107 nack pli\r\na=fmtp:107 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\r\na=rtpmap:108 rtx/90000\r\na=fmtp:108 apt=107\r\na=rtpmap:109 H264/90000\r\na=rtcp-fb:109 goog-remb\r\na=rtcp-fb:109 transport-cc\r\na=rtcp-fb:109 ccm fir\r\na=rtcp-fb:109 nack\r\na=rtcp-fb:109 nack pli\r\na=fmtp:109 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\na=rtpmap:114 rtx/90000\r\na=fmtp:114 apt=109\r\na=rtpmap:115 H264/90000\r\na=rtcp-fb:115 goog-remb\r\na=rtcp-fb:115 transport-cc\r\na=rtcp-fb:115 ccm fir\r\na=rtcp-fb:115 nack\r\na=rtcp-fb:115 nack pli\r\na=fmtp:115 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\r\na=rtpmap:116 rtx/90000\r\na=fmtp:116 apt=115\r\na=rtpmap:117 H264/90000\r\na=rtcp-fb:117 goog-remb\r\na=rtcp-fb:117 transport-cc\r\na=rtcp-fb:117 ccm fir\r\na=rtcp-fb:117 nack\r\na=rtcp-fb:117 nack pli\r\na=fmtp:117 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f\r\na=rtpmap:118 rtx/90000\r\na=fmtp:118 apt=117\r\na=rtpmap:39 H264/90000\r\na=rtcp-fb:39 goog-remb\r\na=rtcp-fb:39 transport-cc\r\na=rtcp-fb:39 ccm fir\r\na=rtcp-fb:39 nack\r\na=rtcp-fb:39 nack pli\r\na=fmtp:39 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f\r\na=rtpmap:40 rtx/90000\r\na=fmtp:40 apt=39\r\na=rtpmap:45 AV1/90000\r\na=rtcp-fb:45 goog-remb\r\na=rtcp-fb:45 transport-cc\r\na=rtcp-fb:45 ccm fir\r\na=rtcp-fb:45 nack\r\na=rtcp-fb:45 nack pli\r\na=fmtp:45 level-idx=5;profile=0;tier=0\r\na=rtpmap:46 rtx/90000\r\na=fmtp:46 apt=45\r\na=rtpmap:98 VP9/90000\r\na=rtcp-fb:98 goog-remb\r\na=rtcp-fb:98 transport-cc\r\na=rtcp-fb:98 ccm fir\r\na=rtcp-fb:98 nack\r\na=rtcp-fb:98 nack pli\r\na=fmtp:98 profile-id=0\r\na=rtpmap:99 rtx/90000\r\na=fmtp:99 apt=98\r\na=rtpmap:100 VP9/90000\r\na=rtcp-fb:100 goog-remb\r\na=rtcp-fb:100 transport-cc\r\na=rtcp-fb:100 ccm fir\r\na=rtcp-fb:100 nack\r\na=rtcp-fb:100 nack pli\r\na=fmtp:100 profile-id=2\r\na=rtpmap:101 rtx/90000\r\na=fmtp:101 apt=100\r\na=rtpmap:119 red/90000\r\na=rtpmap:120 rtx/90000\r\na=fmtp:120 apt=119\r\na=rtpmap:121 ulpfec/90000\r\na=ssrc-group:FID 1810451430 2342397672\r\na=ssrc:1810451430 cname:NKFwP7Fvk/3zlXSL\r\na=ssrc:1810451430 msid:077a17e7-aec0-4aec-bef9-da6e02d3458f 593c4b1b-5b4b-4bed-b64a-2da1011de475\r\na=ssrc:2342397672 cname:NKFwP7Fvk/3zlXSL\r\na=ssrc:2342397672 msid:077a17e7-aec0-4aec-bef9-da6e02d3458f 593c4b1b-5b4b-4bed-b64a-2da1011de475\r\n",
};
function IntegrationView(props) {
const title = createMemo(() =>
integration() && typeof integration === "function"
? integration().getName()
: "Integration"
);
async function getSDP() {
let configuration = {
iceServers: [],
};
let mediaTrack = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
let rtcPeerConnection = new RTCPeerConnection(configuration);
mediaTrack
.getTracks()
.forEach((track) => rtcPeerConnection.addTrack(track, mediaTrack));
rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(offer));
let response = await rtcPeerConnection.createAnswer();
console.log(JSON.stringify(response));
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<span>Integration</span>
<h1>{title}</h1>
<button class="btn btn-primary" onClick={() => getSDP()}>
get SDP
</button>
</div>
);
}
IntegrationView.setIntegration = setIntegration;
export default IntegrationView;

View File

@ -1,6 +1,6 @@
import { createSignal, mergeProps } from "solid-js";
import UserService from "../services/user-service";
import Spinner from "../modules/spinner";
import Spinner from "../components/spinner";
const IDLE = "idle";
const LOGGING_IN = "logging-in";

View File

@ -1,31 +1,32 @@
import { autoUpdate, computePosition, offset, shift } from "@floating-ui/dom";
import {
createEffect,
createResource,
createSignal,
mergeProps,
createResource,
createEffect,
onMount,
onCleanup,
onMount,
} from "solid-js";
import { computePosition, shift, autoUpdate, offset } from "@floating-ui/dom";
import UserService from "../services/user-service.js";
import ModalRegistry from "../modals/modal-registry.jsx";
import UserService from "../services/user-service.js";
import UrlUtils from "../tools/url-utils.js";
import DevicesView from "./devices/devices-view.jsx";
import SettingsView from "./settings-view.jsx";
import DevicesView from "./devices-view.jsx";
import {
DEVICES_VIEW,
REMOTES_VIEW,
RECORDINGS_VIEW,
SETTINGS_VIEW,
INTEGRATION_VIEW,
RECORDINGS_VIEW,
REMOTES_VIEW,
SETTINGS_VIEW,
} from "../data/constants.js";
import IntegrationView from "./integration-view.jsx";
import IntegrationView from "./devices/integration-view.jsx";
import RemotesView from "./remotes/remotes-view.jsx";
let [activeView, setActiveView] = createSignal(DEVICES_VIEW);
const MainView = function (props) {
function MainView(props) {
props = mergeProps({ onLogout: () => {} }, props);
const [userInfo] = createResource(() => UserService.getUserInfo());
@ -54,7 +55,6 @@ const MainView = function (props) {
function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("view");
console.log(view)
if (view) {
setActiveView(view);
}
@ -99,7 +99,8 @@ const MainView = function (props) {
if (activeView() === view) return;
setActiveView(view);
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "view", view);
url = UrlUtils.addQueryParameter(url, "view", activeView());
url = UrlUtils.removeQueryParameter(url, "tab");
UrlUtils.setUrl(url);
}
@ -113,7 +114,7 @@ const MainView = function (props) {
<div class="d-flex flex-column" style="height:100vh">
<Modals></Modals>
<HeaderBar></HeaderBar>
<ActiveView class={"bg-body-tertiary"}></ActiveView>
<ActiveView class={"bg-body-tertiary flex-fill"}></ActiveView>
</div>
);
}
@ -210,18 +211,24 @@ const MainView = function (props) {
<SettingsView {...props} />
</Match>
<Match when={activeView() === DEVICES_VIEW}>
<DevicesView {...props} onIntegrationClicked={handleIntegrationClicked} />
<DevicesView
{...props}
onIntegrationClicked={handleIntegrationClicked}
/>
</Match>
<Match when={activeView() === INTEGRATION_VIEW}>
<IntegrationView {...props} />
</Match>
<Match when={activeView() === REMOTES_VIEW}>
<RemotesView {...props} />
</Match>
</Switch>
</div>
);
}
return render();
};
}
MainView.setActiveView = setActiveView;

View File

@ -0,0 +1,142 @@
import { createResource, onMount } from "solid-js";
import List from "../../components/list";
import RemotesService from "../../services/remotes-service";
import CreateCommandModal from "../../modals/create-command-modal";
import DeleteCommandModal from "../../modals/delete-command-modal";
import ImportCommandsModal from "../../modals/import-commands-modal";
import EditCommandModal from "../../modals/edit-command-modal";
function CommandsList(props) {
const [commands, { refetch: refetchCommands }] = createResource(
RemotesService.getCommands
);
onMount(() => {
refetchCommands();
});
CreateCommandModal.onCommandCreated(() => {
refetchCommands();
});
ImportCommandsModal.onCommandsImported(() => {
refetchCommands();
});
DeleteCommandModal.onCommandDeleted(() => {
refetchCommands();
});
EditCommandModal.onCommandEdited(() => {
refetchCommands();
});
function handleNewCommand() {
refetchCommands();
CreateCommandModal.Handler.show();
}
function handleDeleteCommand(command) {
DeleteCommandModal.setCommand(command);
DeleteCommandModal.Handler.show();
}
function handleImportCommands() {
ImportCommandsModal.Handler.show();
}
function handleEditCommand(command) {
EditCommandModal.setCommand(command);
EditCommandModal.Handler.show();
}
return (
<>
<div class="d-flex flex-row">
<div class="d-flex flex-row flex-fill">
{props.navigation ? props.navigation : null}
</div>
<div class="d-flex flex-row justify-content-end flex-fill">
<button class="btn btn-dark me-2 mb-3" onClick={handleImportCommands}>
<i class="bi bi-box-arrow-in-down me-2"></i>
Import
</button>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewCommand}>
<i class="bi bi-plus-square me-2"></i>
New
</button>
</div>
</div>
<List
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "title",
name: "Title",
},
{
id: "protocol",
name: "Protocol",
width: 8,
},
{
id: "type",
name: "Type",
width: 6,
},
{
id: "options",
name: "",
width: 6,
},
]}
items={(commands() || []).map((command) => ({
id: {
html: <span class="font-monospace">{command.getId()}</span>,
},
title: {
text: command.getTitle(),
},
protocol: {
text: command.getProtocol(),
},
type: {
text: command.getCommandType(),
},
options: {
html: (
<>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleEditCommand(command);
}}
>
<i class="bi bi-pencil-fill"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleDeleteCommand(command);
}}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
command,
}))}
></List>
</>
);
}
export default CommandsList;

View File

@ -0,0 +1,146 @@
import { createMemo, createResource, createSignal, onMount } from "solid-js";
import List from "../../components/list";
import RemotesService from "../../services/remotes-service";
import CreateRemoteModal from "../../modals/create-remote-modal";
import DeleteRemoteModal from "../../modals/delete-remote-modal";
import FileUtils from "../../tools/file-utils";
import Serializer from "../../data/serializer";
import RemoteService from "../../services/remotes-service";
import EditRemoteModal from "../../modals/edit-remote-modal";
function RemotesList(props) {
const [remotes, { refetch: refetchRemotes }] = createResource(
RemotesService.getRemotes
);
const [selectedRemotes, setSelectedRemotes] = createSignal([]);
const canExport = createMemo(() => selectedRemotes().length > 0);
onMount(() => {
refetchRemotes();
});
CreateRemoteModal.onRemoteCreated(() => {
refetchRemotes();
});
DeleteRemoteModal.onRemoteDeleted(() => {
refetchRemotes();
});
EditRemoteModal.onRemoteEdited(() => {
refetchRemotes();
});
function handleNewRemote() {
CreateRemoteModal.Handler.show();
}
function handleEditRemote(remote) {
EditRemoteModal.setRemoteId(remote.getId());
EditRemoteModal.Handler.show();
}
function handleDeleteRemote(remote) {
DeleteRemoteModal.setRemote(remote);
DeleteRemoteModal.Handler.show();
}
function handleListItemsSelect(items) {
setSelectedRemotes(items.map((item) => item.remote));
}
async function handleExport() {
let remotes = await Promise.all(
selectedRemotes().map((remote) => RemoteService.getRemote(remote.getId()))
);
let files = remotes.map((remote) =>
FileUtils.createJsonFile(
Serializer.serializeRemote(remote),
`${remote.getTitle()}.remote.json`
)
);
if (files.length >= 1) {
FileUtils.downloadFile(files[0]);
}
}
return (
<>
<div class="d-flex flex-row">
<div class="d-flex flex-row flex-fill">
{props.navigation ? props.navigation : null}
</div>
<div class="d-flex flex-row justify-content-end flex-fill">
<button
class="btn btn-dark me-2 mb-3"
disabled={!canExport()}
onClick={handleExport}
>
<i class="bi bi-box-arrow-up me-2"></i>
Export
</button>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewRemote}>
<i class="bi bi-plus-square me-2"></i>
New
</button>
</div>
</div>
<List
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "title",
name: "Title",
},
{
id: "options",
name: "",
width: 6,
},
]}
onListItemClick={() => {}}
onListItemsSelect={handleListItemsSelect}
items={(remotes() || []).map((remote) => ({
id: {
html: <span class="font-monospace">{remote.getId()}</span>,
},
title: {
text: remote.getTitle(),
},
options: {
html: (
<>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleEditRemote(remote);
}}
>
<i class="bi bi-pencil-fill"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleDeleteRemote(remote);
}}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
remote,
}))}
></List>
</>
);
}
export default RemotesList;

View File

@ -0,0 +1,92 @@
import {
createEffect,
createSignal,
Match,
mergeProps,
onCleanup,
Switch,
} from "solid-js";
import UrlUtils from "../../tools/url-utils";
import CommandsList from "./commands-list-view";
import RemotesList from "./remotes-list-view";
function RemotesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props);
const REMOTES_LIST_VIEW = "remotes_list";
const COMMANDS_LIST_VIEW = "commands_list";
const VIEWS = [REMOTES_LIST_VIEW, COMMANDS_LIST_VIEW];
const [currentView, setCurrentView] = createSignal(REMOTES_LIST_VIEW);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "tab", currentView());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setViewFromUrl);
});
setViewFromUrl();
window.addEventListener("popstate", setViewFromUrl);
function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("tab");
if (!view) return;
if (!VIEWS.includes(view)) return;
setCurrentView(view);
}
function Navigation(props) {
return (
<div class="d-flex flex-row flex-fill">
<button
class={
"btn me-2 mb-3" +
(currentView() === REMOTES_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(REMOTES_LIST_VIEW)}
>
<i class="bi bi-tv me-2"></i>
Remotes
</button>
<button
class={
"btn me-2 mb-3" +
(currentView() === COMMANDS_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(COMMANDS_LIST_VIEW)}
>
<i class="bi bi-gear me-2"></i>
Commands
</button>
</div>
);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<Switch>
<Match when={currentView() === REMOTES_LIST_VIEW}>
<RemotesList navigation={<Navigation />} />
</Match>
<Match when={currentView() === COMMANDS_LIST_VIEW}>
<CommandsList navigation={<Navigation />} />
</Match>
</Switch>
</div>
);
}
export default RemotesView;

View File

@ -1,6 +1,6 @@
import { Show, createEffect, createResource, createSignal } from "solid-js";
import ChangePasswordSettings from "../modules/settings/change-password";
import UserList from "../modules/settings/users-list";
import ChangePasswordSettings from "../components/settings/change-password";
import UserList from "../components/settings/users-list";
import UserService from "../services/user-service";
function SettingsView(props) {