feat: persisting remotes and commands in database

This commit is contained in:
Fritz Heiden 2025-04-12 16:35:03 +02:00
parent db1beac033
commit 6cefa7392c
12 changed files with 576 additions and 64 deletions

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"`
}

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"`
}

200
data/remote_database.go Normal file
View File

@ -0,0 +1,200 @@
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) 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) 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) 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

@ -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)
@ -71,6 +86,11 @@ func main() {
deviceApiHandler.SetRouter(webServer.Router())
deviceApiHandler.Initialize(&authenticator)
remoteApiHandler := server.RemoteApiHandler{}
remoteApiHandler.SetRemoteManager(&remoteManager)
remoteApiHandler.SetRouter(webServer.Router())
remoteApiHandler.Initialize(&authenticator)
webSocketServer := server.WebsocketServer{}
webSocketServer.SetRouter(webServer.Router())
webSocketServer.Initialize(&authenticator)

View File

@ -0,0 +1,50 @@
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) GetRemotes() ([]d.Remote, error) {
remotes, err := rm.remoteDatabase.GetRemotes()
if err != nil {
return nil, err
}
return remotes, nil
}
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) SetRemoteDatabase(remoteDatabase *d.RemoteDatabase) {
rm.remoteDatabase = remoteDatabase
}
func (rm *RemoteManager) DeleteCommand(commandID string) error {
return rm.remoteDatabase.DeleteCommand(commandID)
}

View File

@ -0,0 +1,103 @@
package server
import (
d "playback-device-server/data"
m "playback-device-server/management"
"github.com/labstack/echo/v4"
)
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.POST("", r.handleCreateRemote)
remotesApi.DELETE("/:id", r.handleDelteRemote)
r.router.Use(authenticator.Authenticate("/api/commands", []string{}))
commandsApi := r.router.Group("/api/commands")
commandsApi.GET("", r.handleGetCommands)
commandsApi.POST("", r.handleCreateCommands)
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) 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) handleDelteRemote(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) 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,11 +1,14 @@
function Command({
id,
protocol,
commandNumber,
device,
commandType,
title,
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;
@ -34,6 +37,8 @@ function Command({
}
function setCommandNumber(commandNumber) {
if (typeof commandNumber !== "number")
throw new Error("Command number must be a number");
_commandNumber = commandNumber;
}
@ -42,6 +47,7 @@ function Command({
}
function setDevice(device) {
if (typeof device !== "number") throw new Error("Device must be a number");
_device = device;
}

View File

@ -32,6 +32,22 @@ 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);
}
@ -41,6 +57,16 @@ const Serializer = (function () {
return objects.map((object) => deserializeCommand(object));
}
function serializeRemote(remote) {
return {
id: remote.getId(),
title: remote.getTitle(),
commands: remote.getCommands().map((command) => ({
id: command.getId(),
})),
};
}
function deserializeRemote(object) {
return new Remote(object);
}
@ -57,8 +83,11 @@ const Serializer = (function () {
deserializeDevices,
deserializeIntegration,
deserializeIntegrations,
serializeCommand,
serializeCommands,
deserializeCommand,
deserializeCommands,
serializeRemote,
deserializeRemote,
deserializeRemotes,
};

View File

@ -24,7 +24,9 @@ function CreateCommandModal(props) {
);
const isDeviceValid = createMemo(() => device() !== "" && !isNaN(device()));
const isCommandTypeValid = createMemo(() => commandType() !== "");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
const isTitleValid = createMemo(
() => title() === "" || title().length >= MIN_TITLE_LENGTH
);
const isFormValid = createMemo(
() =>
@ -41,8 +43,8 @@ function CreateCommandModal(props) {
command = await RemotesService.createCommand(
new Command({
protocol: protocol(),
commandNumber: commandNumber(),
device: device(),
commandNumber: parseInt(commandNumber()),
device: parseInt(device()),
commandType: commandType(),
title: title(),
})

View File

@ -37,6 +37,7 @@ function CreateRemoteModal(props) {
);
} catch (e) {
setError(e.message);
console.error(e);
return;
}
resetFields();

View File

@ -360,7 +360,11 @@ function ImportCommandsModal(props) {
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;

View File

@ -1,78 +1,93 @@
import Command from "../data/command";
import Serializer from "../data/serializer";
import Net from "../tools/net";
function RemotesService() {
let _commands = [
new Command({
id: 1,
protocol: "samsung",
commandNumber: 1,
device: 7,
commandType: "power",
title: "Power Samsung",
}),
new Command({
id: 2,
protocol: "samsung",
commandNumber: 2,
device: 7,
commandType: "input",
title: "Input Samsung",
}),
new Command({
id: 3,
protocol: "samsung",
commandNumber: 3,
device: 7,
commandType: "volume_up",
title: "Volume Up Samsung",
}),
];
function RemoteService() {
let remotes = [];
async function getRemotes() {
return [].concat(remotes);
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 id = Math.random().toString(36).substr(2, 9);
remote.setId(id);
remotes.push(remote);
return 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 deleteRemote(remoteId) {
let index = remotes.findIndex((remote) => remote.getId() === remoteId);
if (index >= 0) {
remotes.splice(index, 1);
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() {
return [].concat(_commands);
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) {
let id = Math.random().toString(36).substr(2, 9);
command.setId(id);
_commands.push(command);
return command;
return createCommands([command]);
}
async function createCommands(commands) {
if (!commands || commands.length === 0) return [];
commands.forEach((command) => {
let id = Math.random().toString(36).substr(2, 9);
command.setId(id);
_commands.push(command);
let commandObjects = Serializer.serializeCommands(commands);
let response = await Net.sendJsonRequest({
method: "POST",
url: "/api/commands",
data: commandObjects,
});
return commands;
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function deleteCommand(commandId) {
let index = _commands.findIndex((command) => command.getId() === commandId);
if (index >= 0) {
_commands.splice(index, 1);
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);
}
}
@ -87,6 +102,6 @@ function RemotesService() {
};
}
RemotesService = new RemotesService();
RemoteService = new RemoteService();
export default RemotesService;
export default RemoteService;