Stian Fredrik Aune
4 years ago
25 changed files with 24454 additions and 11560 deletions
-
88app/api/bridges.go
-
30app/api/util.go
-
2app/config/channels.go
-
14app/config/repo.go
-
3app/server.go
-
3app/services/events.go
-
46app/services/publish.go
-
4cmd/goose/main.go
-
78internal/mysql/bridgerepo.go
-
17internal/mysql/util.go
-
19models/bridge.go
-
2models/errors.go
-
9models/event.go
-
20scripts/20210522140146_device.sql
-
17scripts/20210522140147_device_property.sql
-
20scripts/20210522140148_device_state.sql
-
91webui/package.json
-
57webui/src/App.tsx
-
20webui/src/index.css
-
34webui/src/index.tsx
-
17webui/src/primitives/Layout.sass
-
45webui/src/primitives/Layout.tsx
-
7webui/src/res/colors.sass
-
11959webui/yarn-error.log
-
23412webui/yarn.lock
@ -1 +1,89 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"git.aiterp.net/lucifer/new-server/app/config" |
|||
"git.aiterp.net/lucifer/new-server/models" |
|||
"github.com/gin-gonic/gin" |
|||
"log" |
|||
) |
|||
|
|||
func Bridges(r gin.IRoutes) { |
|||
r.GET("", handler(func(c *gin.Context) (interface{}, error) { |
|||
return config.BridgeRepository().FetchAll(ctxOf(c)) |
|||
})) |
|||
|
|||
r.POST("", handler(func(c *gin.Context) (interface{}, error) { |
|||
var body struct { |
|||
Driver models.DriverKind `json:"driver"` |
|||
Address string `json:"address"` |
|||
DryRun bool `json:"dryRun"` |
|||
} |
|||
err := parseBody(c, &body) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
driver, err := config.DriverProvider().Provide(body.Driver) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.DryRun) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
if !body.DryRun { |
|||
for _, bridge := range bridges { |
|||
err := config.BridgeRepository().Save(ctxOf(c), &bridge) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
log.Printf("Saved new bridge: %s (%s)", bridge.Name, bridge.Address) |
|||
} |
|||
} |
|||
|
|||
return bridges, nil |
|||
})) |
|||
|
|||
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) { |
|||
var body struct { |
|||
Name *string `json:"name"` |
|||
} |
|||
err := parseBody(c, &body) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id")) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
if body.Name != nil { |
|||
bridge.Name = *body.Name |
|||
} |
|||
|
|||
err = config.BridgeRepository().Save(ctxOf(c), &bridge) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return bridge, nil |
|||
})) |
|||
|
|||
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) { |
|||
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id")) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
err = config.BridgeRepository().Delete(ctxOf(c), &bridge) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return bridge, nil |
|||
})) |
|||
} |
@ -1 +1,47 @@ |
|||
package services |
|||
|
|||
import ( |
|||
"context" |
|||
"git.aiterp.net/lucifer/new-server/app/config" |
|||
"git.aiterp.net/lucifer/new-server/models" |
|||
"log" |
|||
) |
|||
|
|||
func StartPublisher() { |
|||
ctx := context.Background() |
|||
|
|||
go func() { |
|||
for devices := range config.PublishChannel { |
|||
if len(devices) == 0 { |
|||
continue |
|||
} |
|||
|
|||
// Emergency solution! Please avoid!
|
|||
// Send devices not belonging to the first channel separately
|
|||
bridgeID := devices[0].BridgeID |
|||
for _, device := range devices { |
|||
if device.BridgeID != bridgeID { |
|||
config.PublishChannel<-[]models.Device{device} |
|||
} |
|||
} |
|||
|
|||
bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID) |
|||
if err != nil { |
|||
log.Println("Publishing error (1): " + err.Error()) |
|||
continue |
|||
} |
|||
|
|||
driver, err := config.DriverProvider().Provide(bridge.Driver) |
|||
if err != nil { |
|||
log.Println("Publishing error (2): " + err.Error()) |
|||
continue |
|||
} |
|||
|
|||
err = driver.Publish(ctx, bridge, devices) |
|||
if err != nil { |
|||
log.Println("Publishing error (3): " + err.Error()) |
|||
continue |
|||
} |
|||
} |
|||
}() |
|||
} |
@ -1 +1,79 @@ |
|||
package mysql |
|||
|
|||
import ( |
|||
"context" |
|||
"git.aiterp.net/lucifer/new-server/models" |
|||
"github.com/jmoiron/sqlx" |
|||
) |
|||
|
|||
type BridgeRepo struct { |
|||
DBX *sqlx.DB |
|||
} |
|||
|
|||
func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) { |
|||
var bridge models.Bridge |
|||
err := b.DBX.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id = ?", id) |
|||
if err != nil { |
|||
return models.Bridge{}, dbErr(err) |
|||
} |
|||
|
|||
return bridge, nil |
|||
} |
|||
|
|||
func (b *BridgeRepo) FetchAll(ctx context.Context) ([]models.Bridge, error) { |
|||
bridges := make([]models.Bridge, 0, 8) |
|||
err := b.DBX.GetContext(ctx, bridges, "SELECT * FROM bridge") |
|||
if err != nil { |
|||
return nil, dbErr(err) |
|||
} |
|||
|
|||
return bridges, nil |
|||
} |
|||
|
|||
func (b *BridgeRepo) Save(ctx context.Context, bridge *models.Bridge) error { |
|||
if bridge.ID > 0 { |
|||
_, err := b.DBX.ExecContext( |
|||
ctx, |
|||
"UPDATE bridge SET name = ?, address = ?, token = ? WHERE id = ?", |
|||
bridge.Name, bridge.Address, bridge.Token, bridge.ID, |
|||
) |
|||
|
|||
if err != nil { |
|||
return dbErr(err) |
|||
} |
|||
} else { |
|||
rs, err := b.DBX.ExecContext( |
|||
ctx, |
|||
"INSERT INTO bridge (name, driver, address, token) VALUES (?, ?, ?, ?)", |
|||
bridge.Name, bridge.Driver, bridge.Address, bridge.Token, |
|||
) |
|||
|
|||
if err != nil { |
|||
return dbErr(err) |
|||
} |
|||
|
|||
id, err := rs.LastInsertId() |
|||
if err != nil { |
|||
return dbErr(err) |
|||
} |
|||
|
|||
bridge.ID = int(id) |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (b *BridgeRepo) Delete(ctx context.Context, bridge *models.Bridge) error { |
|||
_, err := b.DBX.ExecContext(ctx, "DELETE FROM bridge WHERE id = ?", bridge.ID) |
|||
if err != nil { |
|||
return dbErr(err) |
|||
} |
|||
|
|||
_, err = b.DBX.ExecContext(ctx, "DELETE FROM device WHERE bridge_id = ?", bridge.ID) |
|||
if err != nil { |
|||
return dbErr(err) |
|||
} |
|||
|
|||
bridge.ID = 0 |
|||
return nil |
|||
} |
@ -1 +1,18 @@ |
|||
package mysql |
|||
|
|||
import ( |
|||
"database/sql" |
|||
"git.aiterp.net/lucifer/new-server/models" |
|||
"log" |
|||
) |
|||
|
|||
func dbErr(err error) error { |
|||
if err == sql.ErrNoRows { |
|||
return models.ErrNotFound |
|||
} else if err != nil { |
|||
log.Printf("Internal error: %s", err.Error()) |
|||
return models.ErrInternal |
|||
} |
|||
|
|||
return nil |
|||
} |
@ -0,0 +1,20 @@ |
|||
-- +goose Up |
|||
-- +goose StatementBegin |
|||
CREATE TABLE device |
|||
( |
|||
id INT NOT NULL AUTO_INCREMENT, |
|||
bridge_id INT NOT NULL, |
|||
internal_id VARCHAR(255) NOT NULL, |
|||
icon CHAR NOT NULL, |
|||
name VARCHAR(255) NOT NULL, |
|||
capabilities VARCHAR(255) NOT NULL, |
|||
button_names VARCHAR(255) NOT NULL, |
|||
|
|||
PRIMARY KEY (id) |
|||
); |
|||
-- +goose StatementEnd |
|||
|
|||
-- +goose Down |
|||
-- +goose StatementBegin |
|||
DROP TABLE device; |
|||
-- +goose StatementEnd |
@ -0,0 +1,17 @@ |
|||
-- +goose Up |
|||
-- +goose StatementBegin |
|||
CREATE TABLE device_property |
|||
( |
|||
device_id INT NOT NULL, |
|||
prop_key VARCHAR(255) NOT NULL, |
|||
prop_value VARCHAR(255) NOT NULL, |
|||
is_user TINYINT NOT NULL DEFAULT 0, |
|||
|
|||
PRIMARY KEY (device_id, prop_key) |
|||
); |
|||
-- +goose StatementEnd |
|||
|
|||
-- +goose Down |
|||
-- +goose StatementBegin |
|||
DROP TABLE device_property; |
|||
-- +goose StatementEnd |
@ -0,0 +1,20 @@ |
|||
-- +goose Up |
|||
-- +goose StatementBegin |
|||
CREATE TABLE device_state |
|||
( |
|||
device_id INT NOT NULL, |
|||
hue DOUBLE NOT NULL, |
|||
saturation DOUBLE NOT NULL, |
|||
kelvin INT NOT NULL, |
|||
power TINYINT NOT NULL, |
|||
intensity INT NOT NULL, |
|||
|
|||
PRIMARY KEY (device_id) |
|||
); |
|||
|
|||
-- +goose StatementEnd |
|||
|
|||
-- +goose Down |
|||
-- +goose StatementBegin |
|||
DROP TABLE device_state; |
|||
-- +goose StatementEnd |
@ -1,43 +1,48 @@ |
|||
{ |
|||
"name": "webui", |
|||
"version": "0.1.0", |
|||
"private": true, |
|||
"dependencies": { |
|||
"@testing-library/jest-dom": "^5.11.4", |
|||
"@testing-library/react": "^11.1.0", |
|||
"@testing-library/user-event": "^12.1.10", |
|||
"@types/jest": "^26.0.15", |
|||
"@types/node": "^12.0.0", |
|||
"@types/react": "^17.0.0", |
|||
"@types/react-dom": "^17.0.0", |
|||
"react": "^17.0.2", |
|||
"react-dom": "^17.0.2", |
|||
"react-scripts": "4.0.3", |
|||
"typescript": "^4.1.2", |
|||
"web-vitals": "^1.0.1" |
|||
}, |
|||
"scripts": { |
|||
"start": "react-scripts start", |
|||
"build": "react-scripts build", |
|||
"test": "react-scripts test", |
|||
"eject": "react-scripts eject" |
|||
}, |
|||
"eslintConfig": { |
|||
"extends": [ |
|||
"react-app", |
|||
"react-app/jest" |
|||
] |
|||
}, |
|||
"browserslist": { |
|||
"production": [ |
|||
">0.2%", |
|||
"not dead", |
|||
"not op_mini all" |
|||
], |
|||
"development": [ |
|||
"last 1 chrome version", |
|||
"last 1 firefox version", |
|||
"last 1 safari version" |
|||
] |
|||
} |
|||
} |
|||
{ |
|||
"name": "webui", |
|||
"version": "0.1.0", |
|||
"private": true, |
|||
"dependencies": { |
|||
"@testing-library/jest-dom": "^5.11.4", |
|||
"@testing-library/react": "^11.1.0", |
|||
"@testing-library/user-event": "^12.1.10", |
|||
"@types/jest": "^26.0.15", |
|||
"@types/node": "^12.0.0", |
|||
"@types/react": "^17.0.0", |
|||
"@types/react-dom": "^17.0.0", |
|||
"hookrouter": "^1.2.5", |
|||
"node-sass": "4.14.1", |
|||
"react": "^17.0.2", |
|||
"react-dom": "^17.0.2", |
|||
"react-scripts": "4.0.3", |
|||
"typescript": "^4.1.2", |
|||
"web-vitals": "^1.0.1" |
|||
}, |
|||
"scripts": { |
|||
"start": "react-scripts start", |
|||
"build": "react-scripts build", |
|||
"test": "react-scripts test", |
|||
"eject": "react-scripts eject" |
|||
}, |
|||
"eslintConfig": { |
|||
"extends": [ |
|||
"react-app", |
|||
"react-app/jest" |
|||
] |
|||
}, |
|||
"browserslist": { |
|||
"production": [ |
|||
">0.2%", |
|||
"not dead", |
|||
"not op_mini all" |
|||
], |
|||
"development": [ |
|||
"last 1 chrome version", |
|||
"last 1 firefox version", |
|||
"last 1 safari version" |
|||
] |
|||
}, |
|||
"devDependencies": { |
|||
"@types/hookrouter": "^2.2.5" |
|||
} |
|||
} |
@ -1,26 +1,31 @@ |
|||
import React from 'react'; |
|||
import logo from './logo.svg'; |
|||
import './App.css'; |
|||
|
|||
function App() { |
|||
return ( |
|||
<div className="App"> |
|||
<header className="App-header"> |
|||
<img src={logo} className="App-logo" alt="logo" /> |
|||
<p> |
|||
Edit <code>src/App.tsx</code> and save to reload. |
|||
</p> |
|||
<a |
|||
className="App-link" |
|||
href="https://reactjs.org" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
> |
|||
Learn React |
|||
</a> |
|||
</header> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default App; |
|||
import React from 'react'; |
|||
import {HookRouter, navigate, usePath, useRoutes} from "hookrouter"; |
|||
import {Tabs} from "./primitives/Layout"; |
|||
|
|||
const routeObj: HookRouter.RouteObject = { |
|||
"/": () => <div>1</div>, |
|||
"/devices": () => <div>2</div>, |
|||
"/settings": () => <div>3</div>, |
|||
} |
|||
|
|||
const routeList = ["/", "/devices", "/settings"]; |
|||
|
|||
const tabNames = ["Lucifer", "Enheter", "Oppsett"]; |
|||
|
|||
function App() { |
|||
const route = useRoutes(routeObj); |
|||
const path = usePath(); |
|||
|
|||
return ( |
|||
<div className="App"> |
|||
<Tabs tabNames={tabNames} |
|||
index={routeList.indexOf(path)} |
|||
onChange={i => navigate(routeList[i])} |
|||
boldIndex={0} |
|||
/> |
|||
{route || <div>B</div>} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default App; |
@ -1,13 +1,7 @@ |
|||
body { |
|||
margin: 0; |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', |
|||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', |
|||
sans-serif; |
|||
-webkit-font-smoothing: antialiased; |
|||
-moz-osx-font-smoothing: grayscale; |
|||
} |
|||
|
|||
code { |
|||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', |
|||
monospace; |
|||
} |
|||
body, html { |
|||
margin: 0; |
|||
font-family: sans-serif; |
|||
-webkit-font-smoothing: antialiased; |
|||
-moz-osx-font-smoothing: grayscale; |
|||
padding: 0; |
|||
} |
@ -1,17 +1,17 @@ |
|||
import React from 'react'; |
|||
import ReactDOM from 'react-dom'; |
|||
import './index.css'; |
|||
import App from './App'; |
|||
import reportWebVitals from './reportWebVitals'; |
|||
|
|||
ReactDOM.render( |
|||
<React.StrictMode> |
|||
<App /> |
|||
</React.StrictMode>, |
|||
document.getElementById('root') |
|||
); |
|||
|
|||
// If you want to start measuring performance in your app, pass a function
|
|||
// to log results (for example: reportWebVitals(console.log))
|
|||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
|||
reportWebVitals(); |
|||
import React from 'react'; |
|||
import ReactDOM from 'react-dom'; |
|||
import './index.css'; |
|||
import App from './App'; |
|||
import reportWebVitals from "./reportWebVitals"; |
|||
|
|||
ReactDOM.render( |
|||
<React.StrictMode> |
|||
<App /> |
|||
</React.StrictMode>, |
|||
document.getElementById('root') |
|||
); |
|||
|
|||
// If you want to start measuring performance in your app, pass a function
|
|||
// to log results (for example: reportWebVitals(console.log))
|
|||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
|||
reportWebVitals(); |
@ -0,0 +1,17 @@ |
|||
@import "../res/colors" |
|||
|
|||
.Tabs-container |
|||
background-color: $color-foreground-dark |
|||
|
|||
.Tabs-element |
|||
display: inline-block |
|||
padding: 0.25em 0.7ch |
|||
cursor: pointer |
|||
user-select: none |
|||
|
|||
.Tabs-active |
|||
border-bottom: 1px solid $color-foreground |
|||
|
|||
.Tabs-bold |
|||
font-family: 'Bitstream Vera Serif', 'Lucida Fax', serif |
|||
font-weight: 600 |
@ -0,0 +1,45 @@ |
|||
import React, {useCallback, useEffect, useMemo} from "react"; |
|||
import "./Layout.sass"; |
|||
|
|||
interface TabsProps { |
|||
tabNames: string[] |
|||
index: number |
|||
onChange: (newIndex: number) => void |
|||
boldIndex?: number |
|||
} |
|||
|
|||
export function Tabs({tabNames, index, onChange, boldIndex}: TabsProps) { |
|||
useEffect(() => { |
|||
if (index < 0) { |
|||
onChange(0); |
|||
} else if (tabNames.length > 0 && index >= tabNames.length) { |
|||
onChange(tabNames.length - 1); |
|||
} |
|||
}, [tabNames, index, onChange]); |
|||
|
|||
const tabClass = useCallback((i: number) => { |
|||
const classes = ['Tabs-element']; |
|||
|
|||
if (i === index) { |
|||
classes.push("Tabs-active"); |
|||
} |
|||
|
|||
if (i === boldIndex) { |
|||
classes.push("Tabs-bold"); |
|||
} |
|||
|
|||
return classes.join(" "); |
|||
}, [index, boldIndex]) |
|||
|
|||
return ( |
|||
<div className="Tabs-container"> |
|||
{tabNames.map((name, i) => ( |
|||
<div className={tabClass(i)} |
|||
onClick={() => onChange(i)} |
|||
> |
|||
{name} |
|||
</div> |
|||
))} |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,7 @@ |
|||
$color-background: #111 |
|||
$color-foreground: rgb(238, 238, 238) |
|||
$color-foreground-dark: rgba(238, 238, 238, 0.05) |
|||
|
|||
body, html |
|||
background-color: $color-background |
|||
color: $color-foreground |
11959
webui/yarn-error.log
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
23412
webui/yarn.lock
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue