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