Compare commits

...

21 Commits
v1.1.2 ... main

Author SHA1 Message Date
Daniel Svitan
53722bdf18 🐛 Fixes server requests
All checks were successful
Gitea Build Action / build-go (push) Successful in 27s
Gitea Build Action / build-nuxt (push) Successful in 10m40s
2025-06-07 15:49:34 +02:00
Daniel Svitan
cf35326bf2 🔧 Renames commands
All checks were successful
Gitea Build Action / build-go (push) Successful in 51s
Gitea Build Action / build-nuxt (push) Successful in 10m13s
2025-06-07 09:51:30 +02:00
Daniel Svitan
cb397b7cd3 🔨 Updates routes to new version 2025-06-07 09:21:57 +02:00
Daniel Svitan
4aad657c9d Adds websocket for opened
All checks were successful
Gitea Build Action / build-go (push) Successful in 26s
Gitea Build Action / build-nuxt (push) Successful in 10m2s
2025-06-07 09:15:00 +02:00
Daniel Svitan
000c12845c Adds fetching and toggling alerts
All checks were successful
Gitea Build Action / build-go (push) Successful in 28s
Gitea Build Action / build-nuxt (push) Successful in 10m8s
2025-06-07 08:59:34 +02:00
Daniel Svitan
ae7db8290c 🔨 Replaces native fetch with axios
All checks were successful
Gitea Build Action / build-go (push) Successful in 25s
Gitea Build Action / build-nuxt (push) Successful in 10m20s
2025-06-06 22:33:26 +02:00
Daniel Svitan
88c31a5133 🐳 Adds dockerfile
All checks were successful
Gitea Build Action / build-go (push) Successful in 23s
Gitea Build Action / build-nuxt (push) Successful in 10m5s
2025-06-06 22:12:54 +02:00
Daniel Svitan
18a6bef20a Adds dashboard ui
All checks were successful
Gitea Build Action / build-go (push) Successful in 25s
Gitea Build Action / build-nuxt (push) Successful in 10m4s
2025-06-06 11:44:47 +02:00
Daniel Svitan
7b99b75def Fixes token checking
All checks were successful
Gitea Build Action / build-go (push) Successful in 25s
Gitea Build Action / build-nuxt (push) Successful in 10m1s
2025-06-05 16:57:02 +02:00
Daniel Svitan
48e433ff1e 🎨 Prettier formats the code
All checks were successful
Gitea Build Action / build-go (push) Successful in 24s
Gitea Build Action / build-nuxt (push) Successful in 10m0s
2025-06-05 11:54:09 +02:00
Daniel Svitan
f3d43bbd65 🔧 Removes package.json from gitignore (template fucked me over)
All checks were successful
Gitea Build Action / build-go (push) Successful in 24s
Gitea Build Action / build-nuxt (push) Successful in 10m12s
2025-06-05 10:25:40 +02:00
Daniel Svitan
186275d1dd 🧪 Tests CI
Some checks failed
Gitea Build Action / build-go (push) Successful in 23s
Gitea Build Action / build-nuxt (push) Failing after 4m52s
2025-06-05 10:16:35 +02:00
Daniel Svitan
8068f82f13 💚 Fixes CI web build
Some checks failed
Gitea Build Action / build-go (push) Successful in 24s
Gitea Build Action / build-nuxt (push) Failing after 5m2s
2025-06-05 10:09:03 +02:00
Daniel Svitan
c4f2006e8f 💚 Renames CI build file
Some checks failed
Gitea Build Action / build-go (push) Successful in 27s
Gitea Build Action / build-nuxt (push) Failing after 6s
2025-06-05 10:04:48 +02:00
Daniel Svitan
7b50441da2 💚 Adds CI build for web 2025-06-05 10:04:29 +02:00
Daniel Svitan
940f6ea1b5 🔧 Adds CORS
All checks were successful
Gitea Build Action / build (push) Successful in 25s
2025-06-05 09:53:46 +02:00
Daniel Svitan
a48d1238a2 🚧 Adds token form
All checks were successful
Gitea Build Action / build (push) Successful in 28s
2025-06-05 09:48:58 +02:00
Daniel Svitan
7aaa0c0aab 💄 Adds actual favicon
All checks were successful
Gitea Build Action / build (push) Successful in 26s
2025-06-04 21:47:55 +02:00
Daniel Svitan
00bf392931 🎉 Inits web
All checks were successful
Gitea Build Action / build (push) Successful in 36s
2025-06-04 21:38:24 +02:00
Daniel Svitan
7d2ad1cd49 🐛 Fixes memory overflow and removes server health check
All checks were successful
Gitea Build Action / build (push) Successful in 22s
2025-06-02 20:46:55 +02:00
Daniel Svitan
307b467ac1 🚧 Adds checking and reconnecting when wlan is down
All checks were successful
Gitea Build Action / build (push) Successful in 24s
2025-06-01 21:19:13 +02:00
28 changed files with 2678 additions and 153 deletions

View File

@ -3,7 +3,7 @@ run-name: ${{ gitea.actor }} build
on: [push] on: [push]
jobs: jobs:
build: build-go:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -21,3 +21,21 @@ jobs:
run: cd admin && go get . run: cd admin && go get .
- name: "[admin] Build" - name: "[admin] Build"
run: cd admin && go build -v ./... run: cd admin && go build -v ./...
build-nuxt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun 1.2.0
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.0
- name: Display Bun version
run: bun --version
- name: test 1
run: ls
- name: test 2
run: cd web && ls
- name: "[web] Install dependencies"
run: cd web && bun install
- name: "[web] Build"
run: cd web && bun run build

2
.gitignore vendored
View File

@ -85,8 +85,6 @@ __pycache__/
## Node ## Node
node_modules node_modules
package-lock.json
package.json
/src/doc/rustc-dev-guide/mermaid.min.js /src/doc/rustc-dev-guide/mermaid.min.js
## Rustdoc GUI tests ## Rustdoc GUI tests

View File

@ -27,7 +27,7 @@ type ChangeLockReq struct {
} }
type ChangeAlertReq struct { type ChangeAlertReq struct {
Alert bool `json:"alert"` Alert bool `json:"alerts"`
For int `json:"for"` For int `json:"for"`
} }
@ -59,12 +59,12 @@ func makeGetReq(url string) ([]byte, error) {
return nil, err return nil, err
} }
req.Header.Set("Authorization", token) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := http.DefaultClient.Do(req) res, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { res.Body.Close() }() defer func() { _ = res.Body.Close() }()
if res.StatusCode != 200 { if res.StatusCode != 200 {
fmt.Printf("<-- %d\n", res.StatusCode) fmt.Printf("<-- %d\n", res.StatusCode)
@ -84,7 +84,7 @@ func makePostReq(url string, body any) ([]byte, error) {
return nil, err return nil, err
} }
req.Header.Set("Authorization", token) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req) res, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
@ -120,7 +120,7 @@ func main() {
Action: test, Action: test,
}, },
{ {
Name: "open", Name: "opened",
Usage: "get door state (opened or closed)", Usage: "get door state (opened or closed)",
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.BoolFlag{ &cli.BoolFlag{
@ -131,34 +131,38 @@ func main() {
Action: getOpened, Action: getOpened,
}, },
{ {
Name: "lock", Name: "locked",
Usage: "change lock status", Usage: "change lock status",
Commands: []*cli.Command{ Commands: []*cli.Command{
{ {
Name: "on", Name: "yes",
Usage: "lock the door", Usage: "lock the door",
Aliases: []string{"y", "on", "1", "do"},
Action: createManageLock(true), Action: createManageLock(true),
}, },
{ {
Name: "off", Name: "no",
Usage: "unlock the door", Usage: "unlock the door",
Aliases: []string{"n", "off", "0", "undo"},
Action: createManageLock(false), Action: createManageLock(false),
}, },
}, },
Action: getLocked, Action: getLocked,
}, },
{ {
Name: "alert", Name: "alerts",
Usage: "change alert status", Usage: "change alert status",
Commands: []*cli.Command{ Commands: []*cli.Command{
{ {
Name: "on", Name: "yes",
Usage: "resume alerts", Usage: "resume alerts",
Aliases: []string{"y", "on", "1", "do"},
Action: createManageAlert(true), Action: createManageAlert(true),
}, },
{ {
Name: "off", Name: "no",
Usage: "pause alerts", Usage: "pause alerts",
Aliases: []string{"n", "off", "0", "undo"},
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "for", Name: "for",
@ -195,13 +199,13 @@ func test(context.Context, *cli.Command) error {
return nil return nil
} }
func getOpened(ctx context.Context, cmd *cli.Command) error { func getOpened(_ context.Context, cmd *cli.Command) error {
err := load() err := load()
if err != nil { if err != nil {
return err return err
} }
opened, err := makeGetReq(fmt.Sprintf("%s/open", server)) opened, err := makeGetReq(fmt.Sprintf("%s/opened", server))
if err != nil { if err != nil {
return err return err
} }
@ -225,7 +229,7 @@ func getLocked(context.Context, *cli.Command) error {
return err return err
} }
locked, err := makeGetReq(fmt.Sprintf("%s/lock", server)) locked, err := makeGetReq(fmt.Sprintf("%s/locked", server))
if err != nil { if err != nil {
return err return err
} }
@ -245,7 +249,7 @@ func getAlert(context.Context, *cli.Command) error {
return err return err
} }
alert, err := makeGetReq(fmt.Sprintf("%s/alert", server)) alert, err := makeGetReq(fmt.Sprintf("%s/alerts", server))
if err != nil { if err != nil {
return err return err
} }
@ -269,7 +273,7 @@ func createManageLock(locked bool) func(context.Context, *cli.Command) error {
var data ChangeLockReq var data ChangeLockReq
data.Locked = locked data.Locked = locked
_, err = makePostReq(fmt.Sprintf("%s/lock", server), data) _, err = makePostReq(fmt.Sprintf("%s/locked", server), data)
if err != nil { if err != nil {
return err return err
} }
@ -306,7 +310,7 @@ func createManageAlert(alert bool) func(context.Context, *cli.Command) error {
data.For = secs data.For = secs
} }
_, err = makePostReq(fmt.Sprintf("%s/alert", server), data) _, err = makePostReq(fmt.Sprintf("%s/alerts", server), data)
if err != nil { if err != nil {
return err return err
} }
@ -322,7 +326,7 @@ func createManageAlert(alert bool) func(context.Context, *cli.Command) error {
action = "paused" action = "paused"
} }
fmt.Printf("alerts were %sd%s\n", action, rest) fmt.Printf("alerts were %s%s\n", action, rest)
return nil return nil
} }
} }

BIN
door.xcf Normal file

Binary file not shown.

View File

@ -1,3 +1,4 @@
import gc
import utime import utime
import ujson import ujson
import network import network
@ -17,6 +18,7 @@ class App:
opened: bool = False opened: bool = False
previously_opened: bool = False previously_opened: bool = False
wlan: network.WLAN
led = Pin(15, Pin.OUT) led = Pin(15, Pin.OUT)
trigger = Pin(2, Pin.OUT) trigger = Pin(2, Pin.OUT)
@ -55,18 +57,18 @@ class App:
def connect(self): def connect(self):
print("Connecting to Wi-Fi...") print("Connecting to Wi-Fi...")
wlan = network.WLAN(network.STA_IF) self.wlan = network.WLAN(network.STA_IF)
wlan.active(False) self.wlan.active(False)
utime.sleep_ms(250) utime.sleep_ms(250)
wlan.active(True) self.wlan.active(True)
utime.sleep_ms(250) utime.sleep_ms(250)
wlan.connect(self.ssid, self.password) self.wlan.connect(self.ssid, self.password)
utime.sleep_ms(100) utime.sleep_ms(100)
retry_count = 0 retry_count = 0
while not wlan.isconnected(): while not self.wlan.isconnected():
if retry_count >= MAX_CONNECTION_RETRIES: if retry_count >= MAX_CONNECTION_RETRIES:
print("Max connection retries reached") print("Max connection retries reached")
exit(1) exit(1)
@ -80,22 +82,14 @@ class App:
if retry_count % 10 == 0: if retry_count % 10 == 0:
print("Attempting to restart connection...") print("Attempting to restart connection...")
wlan.connect(self.ssid, self.password) self.wlan.connect(self.ssid, self.password)
for _ in range(10): for _ in range(10):
self.led.toggle() self.led.toggle()
utime.sleep_ms(50) utime.sleep_ms(50)
print(f"Connected with IP {wlan.ifconfig()[0]}") print(f"Connected with IP {self.wlan.ifconfig()[0]}")
self.update_server() self.update_server()
def health_check_server(self):
print("Health checking server...", end="\r")
try:
r = urequests.get(f"{self.server}/")
print(f"Server healthy [{r.status_code}]{" " * 8}")
except Exception as e:
print(f"Error occurred: {e}")
def update_server(self): def update_server(self):
print("Updating state...", end="\r") print("Updating state...", end="\r")
data = {"opened": self.opened} data = {"opened": self.opened}
@ -103,32 +97,40 @@ class App:
try: try:
r = urequests.post( r = urequests.post(
f"{self.server}/open", f"{self.server}/opened",
headers={"Authorization": self.token, "Content-Type": "application/json"}, headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
data=raw data=raw
) )
print(f"State updated [{r.status_code}] {r.content.decode()}") print(f"State updated [{r.status_code}] {r.content.decode()}")
except Exception as e: except Exception as e:
print(f"Error occurred: {e}") print(f"Error occurred: {e}")
def measure_distance(self): def measure_distance(self) -> float:
self.trigger.low() self.trigger.low()
utime.sleep_us(2) utime.sleep_us(2)
self.trigger.high() self.trigger.high()
utime.sleep_us(10) utime.sleep_us(10)
self.trigger.low() self.trigger.low()
start_time = utime.ticks_us()
sent_time = utime.ticks_us() sent_time = utime.ticks_us()
while self.echo.value() == 0: while self.echo.value() == 0:
print("hey", end="\r")
sent_time = utime.ticks_us() sent_time = utime.ticks_us()
if sent_time - start_time >= 100_000: # if it takes more than 100ms, stop
return -1
start_time = utime.ticks_us()
received_time = utime.ticks_us() received_time = utime.ticks_us()
while self.echo.value() == 1: while self.echo.value() == 1:
print("ho", end="\r")
received_time = utime.ticks_us() received_time = utime.ticks_us()
if received_time - start_time >= 100_000: # same
return -1
delta_time = received_time - sent_time delta_time = received_time - sent_time
distance = delta_time / 1_000_000 * SOUND_SPEED / 2 distance = delta_time / 1_000_000 * SOUND_SPEED / 2
print(f"Distance: {distance} cm") print(f"Distance: {distance:0<5} cm; mem_free = {gc.mem_free()}")
return distance return distance
@ -139,6 +141,9 @@ class App:
i = 0 i = 0
while True: while True:
try: try:
if not self.wlan.isconnected():
self.connect()
distance = self.measure_distance() distance = self.measure_distance()
self.opened = distance >= THRESHOLD_DISTANCE self.opened = distance >= THRESHOLD_DISTANCE
@ -157,13 +162,14 @@ class App:
self.previously_opened = True self.previously_opened = True
if i >= 20: if i >= 20:
print("Blinking...")
self.led.toggle() self.led.toggle()
utime.sleep_ms(100) utime.sleep_ms(100)
self.led.toggle() self.led.toggle()
utime.sleep_ms(400) utime.sleep_ms(400)
i = 0 i = 0
self.health_check_server() gc.collect()
else: else:
utime.sleep_ms(500) utime.sleep_ms(500)
@ -172,5 +178,6 @@ class App:
i += 1 i += 1
if __name__ == "__main__": if __name__ == "__main__":
App().run() App().run()

View File

@ -9,6 +9,7 @@ require (
) )
require ( require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect

View File

@ -1,5 +1,7 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=

View File

@ -23,13 +23,14 @@ const TimeFormat = "2006-01-02 15:04:05"
var token string var token string
// the condition is: distance >= threshold (is door open) // the condition is: distance >= threshold (is door open)
var opened bool var opened = false
var openedChange = make(chan any)
// is door locked // is door locked
var locked bool = false var locked = false
// alert the user when door locked and but open? // alerts the user when door locked and but open?
var alert bool = false var alerts = false
var gotifyToken string var gotifyToken string
var gotifyURL string var gotifyURL string
@ -44,7 +45,7 @@ type ChangeLockReq struct {
} }
type ChangeAlertReq struct { type ChangeAlertReq struct {
Alert bool `json:"alert"` Alerts bool `json:"alerts"`
For int `json:"for"` For int `json:"for"`
} }
@ -52,15 +53,15 @@ func main() {
_ = godotenv.Load() _ = godotenv.Load()
token = os.Getenv("TOKEN") token = os.Getenv("TOKEN")
alertRaw := strings.ToLower(os.Getenv("USE_ALERTS")) alertsRaw := strings.ToLower(os.Getenv("USE_ALERTS"))
alert = alertRaw == "true" || alertRaw == "t" || alertRaw == "1" || alertRaw == "y" || alertRaw == "yes" alerts = alertsRaw == "true" || alertsRaw == "t" || alertsRaw == "1" || alertsRaw == "y" || alertsRaw == "yes"
gotifyToken = os.Getenv("GOTIFY_TOKEN") gotifyToken = os.Getenv("GOTIFY_TOKEN")
gotifyURL = os.Getenv("GOTIFY_URL") gotifyURL = os.Getenv("GOTIFY_URL")
if alert && gotifyToken == "" { if alerts && gotifyToken == "" {
log.Fatal("GOTIFY_TOKEN can't be empty when alerts are enabled") log.Fatal("GOTIFY_TOKEN can't be empty when alerts are enabled")
} }
if alert && gotifyURL == "" { if alerts && gotifyURL == "" {
log.Fatal("GOTIFY_URL can't be empty when alerts are enabled") log.Fatal("GOTIFY_URL can't be empty when alerts are enabled")
} }
@ -73,6 +74,10 @@ func main() {
LogLevel: log.ERROR, LogLevel: log.ERROR,
})) }))
app.Use(middleware.Secure()) app.Use(middleware.Secure())
app.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
}))
app.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ app.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: "${time_custom} ${method} ${uri} ---> ${status} in ${latency_human} (${bytes_out} bytes)\n", Format: "${time_custom} ${method} ${uri} ---> ${status} in ${latency_human} (${bytes_out} bytes)\n",
@ -110,110 +115,25 @@ func main() {
} }
} }
app.GET("/", func(c echo.Context) error { app.GET("/", helloWorld)
return c.JSON(http.StatusOK, "Hello world!")
})
app.GET("/open", authed(func(c echo.Context) error { app.GET("/opened", authed(getOpened))
mut.Lock() app.GET("/opened/ws", authed(getOpenedWs))
o := opened app.POST("/opened", authed(setOpened))
mut.Unlock()
return c.JSON(http.StatusOK, o) app.GET("/locked", authed(getLocked))
})) app.POST("/locked", authed(setLocked))
app.POST("/open", authed(func(c echo.Context) error { app.GET("/alerts", authed(getAlerts))
var data ChangeOpenReq app.POST("/alerts", authed(setAlerts))
err := c.Bind(&data)
if err != nil {
return c.NoContent(http.StatusBadRequest)
}
if data.Opened == opened {
return c.NoContent(http.StatusOK)
}
mut.Lock()
opened = data.Opened
if locked && alert {
var action string
if opened {
action = "opened"
} else {
action = "closed"
}
go sendAlert(action)
}
mut.Unlock()
return c.NoContent(http.StatusOK)
}))
app.GET("/lock", authed(func(c echo.Context) error {
mut.Lock()
l := locked
mut.Unlock()
return c.JSON(http.StatusOK, l)
}))
app.POST("/lock", authed(func(c echo.Context) error {
var data ChangeLockReq
err := c.Bind(&data)
if err != nil {
return err
}
if data.Locked == locked {
return c.NoContent(http.StatusOK)
}
mut.Lock()
locked = data.Locked
mut.Unlock()
return c.NoContent(http.StatusOK)
}))
app.GET("/alert", authed(func(c echo.Context) error {
mut.Lock()
a := alert
mut.Unlock()
return c.JSON(http.StatusOK, a)
}))
app.POST("/alert", authed(func(c echo.Context) error {
var data ChangeAlertReq
err := c.Bind(&data)
if err != nil {
return err
}
if data.For > 0 {
go func() {
time.Sleep(time.Duration(data.For) * time.Second)
mut.Lock()
alert = !data.Alert
mut.Unlock()
}()
}
mut.Lock()
alert = data.Alert
mut.Unlock()
return c.NoContent(http.StatusOK)
}))
log.Fatal(app.Start(":1323")) log.Fatal(app.Start(":1323"))
} }
func authed(next echo.HandlerFunc) echo.HandlerFunc { func authed(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
if c.Request().Header.Get("Authorization") != token { provided := c.Request().Header.Get("Authorization")
if provided != fmt.Sprintf("Bearer %s", token) {
return c.NoContent(http.StatusUnauthorized) return c.NoContent(http.StatusUnauthorized)
} }

146
server/routes.go Normal file
View File

@ -0,0 +1,146 @@
package main
import (
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
"net/http"
"time"
)
var upgrader = websocket.Upgrader{}
func helloWorld(c echo.Context) error {
return c.JSON(http.StatusOK, "Hello world!")
}
func getOpened(c echo.Context) error {
mut.Lock()
o := opened
mut.Unlock()
return c.JSON(http.StatusOK, o)
}
func getOpenedWs(c echo.Context) error {
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
defer func() { _ = ws.Close() }()
// empty the channel
emptied := false
for !emptied {
select {
case <-openedChange:
default:
emptied = true
}
}
for {
<-openedChange
mut.Lock()
err = ws.WriteJSON(opened)
mut.Unlock()
if err != nil {
log.Error(err)
break
}
}
return err
}
func setOpened(c echo.Context) error {
var data ChangeOpenReq
err := c.Bind(&data)
if err != nil {
return c.NoContent(http.StatusBadRequest)
}
if data.Opened == opened {
return c.NoContent(http.StatusOK)
}
mut.Lock()
opened = data.Opened
if locked && alerts {
var action string
if opened {
action = "opened"
} else {
action = "closed"
}
go sendAlert(action)
}
go func() {
openedChange <- 0
}()
mut.Unlock()
return c.NoContent(http.StatusOK)
}
func getLocked(c echo.Context) error {
mut.Lock()
l := locked
mut.Unlock()
return c.JSON(http.StatusOK, l)
}
func setLocked(c echo.Context) error {
var data ChangeLockReq
err := c.Bind(&data)
if err != nil {
return err
}
if data.Locked == locked {
return c.NoContent(http.StatusOK)
}
mut.Lock()
locked = data.Locked
mut.Unlock()
return c.NoContent(http.StatusOK)
}
func getAlerts(c echo.Context) error {
mut.Lock()
a := alerts
mut.Unlock()
return c.JSON(http.StatusOK, a)
}
func setAlerts(c echo.Context) error {
var data ChangeAlertReq
err := c.Bind(&data)
if err != nil {
return err
}
if data.For > 0 {
log.Infof("pausing alerts for %d seconds", data.For)
go func() {
time.Sleep(time.Duration(data.For) * time.Second)
mut.Lock()
alerts = !data.Alerts
mut.Unlock()
}()
}
mut.Lock()
alerts = data.Alerts
mut.Unlock()
return c.NoContent(http.StatusOK)
}

10
web/.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.idea/
.vscode/
node_modules/
.output/
.data/
.nuxt/
.cache/
.env*

24
web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

6
web/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": false,
"singleQuote": false
}

20
web/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock .
RUN bun install --frozen-lockfile --production
COPY . .
RUN bun run build
FROM oven/bun:1
WORKDIR /app
COPY --from=build /app/.output .
ENV PORT=3000
ENV HOST=0.0.0.0
EXPOSE 3000
CMD ["bun", "/app/server/index.mjs"]

9
web/app.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<UApp>
<NuxtPage />
</UApp>
</template>
<style>
@import "assets/css/main.css";
</style>

2
web/assets/css/main.css Normal file
View File

@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@nuxt/ui";

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

2027
web/bun.lock Normal file

File diff suppressed because it is too large Load Diff

42
web/composables/useAPI.ts Normal file
View File

@ -0,0 +1,42 @@
import type { FetchResponse } from "ofetch"
import { AxiosError, type AxiosResponse } from "axios"
export function useAPI(route: string = "/") {
return `https://api.door.svitan.dev${route}`
}
export async function handleRequestError(error: unknown) {
const toast = useToast()
let message = undefined
if (error instanceof Error) {
message = error.message
}
toast.add({
title: "Error occurred",
description: message,
color: "error",
})
}
export function handleResponse<T extends { toString(): string }>(
response: AxiosResponse<T>,
success: (response: AxiosResponse<T>) => void = () => {}
) {
const token = useToken()
const toast = useToast()
if (response.status === 200) {
success(response)
} else if (response.status === 401) {
toast.add({ title: "Token not valid", color: "error" })
token.value = ""
navigateTo("/token")
} else {
toast.add({
title: "Error occurred",
description: response.data.toString(),
color: "error",
})
}
}

View File

@ -0,0 +1,10 @@
export function useToken() {
return useCookie<string | undefined>("token")
}
export function useHeaders(override: string | undefined = undefined) {
const token = useToken()
return {
Authorization: `Bearer ${override ?? token.value}`,
}
}

6
web/nuxt.config.ts Normal file
View File

@ -0,0 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-05-15",
devtools: { enabled: true },
modules: ["@nuxt/icon", "@nuxt/ui", "@nuxtjs/tailwindcss"],
});

26
web/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/icon": "1.13.0",
"@nuxt/ui": "3.1.3",
"@nuxtjs/tailwindcss": "7.0.0-beta.0",
"axios": "^1.9.0",
"nuxt": "^3.17.5",
"typescript": "^5.6.3",
"valibot": "^1.1.0",
"vue": "^3.5.16",
"vue-router": "^4.5.1"
},
"devDependencies": {
"prettier": "^3.5.3"
}
}

164
web/pages/index.vue Normal file
View File

@ -0,0 +1,164 @@
<template>
<main
class="flex items-center justify-center min-w-screen min-h-screen gap-4 flex-wrap"
>
<div class="flex items-center justify-center flex-col gap-y-2 w-32">
<div
:class="`flex items-center justify-center border-solid border-2 border-${open ? (locked ? 'error' : 'secondary') : 'primary'} rounded-lg w-full h-32`"
>
<UIcon
:name="
open
? 'material-symbols:door-open'
: 'material-symbols:door-front'
"
class="size-12 !w-16 !h-16"
/>
</div>
<p
:class="`flex items-center justify-center text-${open ? (locked ? 'error' : 'secondary') : 'primary'} text-xl w-full h-10`"
>
{{ open ? "Opened" : "Closed" }}
</p>
</div>
<div class="flex items-center justify-center flex-col gap-y-2 w-32">
<div
:class="`flex items-center justify-center border-solid border-2 border-${locked ? 'primary' : 'secondary'} rounded-lg w-full h-32`"
>
<UIcon
:name="
locked
? 'material-symbols:lock'
: 'material-symbols:lock-open'
"
class="size-12 !w-16 !h-16"
/>
</div>
<UButton
:icon="
locked
? 'material-symbols:lock-open'
: 'material-symbols:lock'
"
size="xl"
:color="locked ? 'primary' : 'secondary'"
variant="solid"
class="flex items-center justify-center w-full h-10"
@click="toggleLock"
>
{{ locked ? "Unlock" : "Lock" }}
</UButton>
</div>
<div class="flex items-center justify-center flex-col gap-y-2 w-32">
<div
:class="`flex items-center justify-center border-solid border-2 border-${alert ? 'primary' : 'secondary'} rounded-lg w-full h-32`"
>
<UIcon
:name="
alert
? 'material-symbols:volume-up'
: 'material-symbols:volume-off'
"
class="size-12 !w-16 !h-16"
/>
</div>
<UButton
:icon="
alert
? 'material-symbols:volume-off'
: 'material-symbols:volume-up'
"
size="xl"
:color="alert ? 'primary' : 'secondary'"
variant="solid"
class="flex items-center justify-center w-full h-10"
@click="toggleAlert"
>
{{ alert ? "Turn off" : "Turn on" }}
</UButton>
</div>
</main>
</template>
<script lang="ts" setup>
import axios from "axios"
const token = useToken()
const open = ref(true)
const locked = ref(true)
const alert = ref(false)
const toast = useToast()
async function toggleLock() {
const desired = !locked.value
axios
.post(
useAPI("/locked"),
{
locked: desired,
},
{
headers: useHeaders(),
}
)
.then((res) => {
handleResponse(res, () => {
toast.add({
title: `The door was ${desired ? "locked" : "unlocked"}`,
})
locked.value = desired
})
})
.catch(handleRequestError)
}
async function toggleAlert() {
const desired = !alert.value
axios
.post(useAPI("/alerts"), { alert: desired }, { headers: useHeaders() })
.then((res) => {
handleResponse(res, () => {
toast.add({
title: `Alerts were turned ${desired ? "on" : "off"}`,
})
alert.value = desired
})
})
.catch(handleRequestError)
}
onMounted(() => {
if (!token.value) {
return navigateTo("/token")
}
axios
.get<boolean>(useAPI("/locked"), {
headers: useHeaders(),
})
.then((res) => {
handleResponse(res, (res) => {
locked.value = res.data
})
})
.catch(handleRequestError)
axios
.get<boolean>(useAPI("/alerts"), {
headers: useHeaders(),
})
.then((res) => {
handleResponse(res, (res) => {
alert.value = res.data
})
})
.catch(handleRequestError)
})
</script>

60
web/pages/token.vue Normal file
View File

@ -0,0 +1,60 @@
<template>
<main class="flex items-center justify-center min-w-screen min-h-screen">
<UForm
:schema="schema"
:state="state"
@submit="onSubmit"
class="flex flex-col items-end justify-center gap-y-2"
>
<UFormField label="Token" name="token" size="xl" required>
<UInput
v-model="state.token"
placeholder="Your token..."
size="xl"
/>
</UFormField>
<UButton type="submit" size="xl"> Submit </UButton>
</UForm>
</main>
</template>
<script lang="ts" setup>
import * as v from "valibot"
import type { FormSubmitEvent } from "@nuxt/ui"
import axios from "axios"
const token = useToken()
const schema = v.object({
token: v.pipe(v.string(), v.nonEmpty("Please enter your token")),
})
type Schema = v.InferOutput<typeof schema>
const state = reactive({
token: "",
})
const toast = useToast()
function onSubmit(event: FormSubmitEvent<Schema>) {
const received = event.data.token
axios
.get<boolean>(useAPI("/opened"), {
headers: useHeaders(received),
})
.then((res) => {
handleResponse(res, () => {
toast.add({ title: "Token saved", color: "success" })
token.value = received
navigateTo("/")
})
})
.catch(handleRequestError)
}
onMounted(() => {
if (token.value) {
return navigateTo("/")
}
})
</script>

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

2
web/public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Disallow: /

3
web/server/tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

11
web/tailwind.config.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
purge: [],
darkMode: "class", // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
};

4
web/tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}