diff --git a/art/art.go b/art/art.go new file mode 100644 index 0000000..657de6e --- /dev/null +++ b/art/art.go @@ -0,0 +1,67 @@ +// Copyright 2017 Kaur Kuut +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package art + +import ( + "bytes" + "image" + "image/color" + "image/png" + + "github.com/pkg/errors" +) + +const ( + White = iota + LightGray + Gray + Black + Pink + Red + Orange + Brown + Yellow + LightGreen + Green + Cyan + MediumBlue + DarkBlue + LightPurple + DarkPurple +) + +type Pixel struct { + X int + Y int + C int +} + +func ParseImage(data []byte) (image.Image, error) { + buf := bytes.NewBuffer(data) + img, err := png.Decode(buf) + if err != nil { + return nil, errors.Wrap(err, "Failed to decode image") + } + if img.Bounds().Min.X != 0 || img.Bounds().Min.Y != 0 || img.Bounds().Max.X != 1000 || img.Bounds().Max.Y != 1000 { + return nil, errors.New("Unexpected image bounds") + } + return img, nil +} + +func SameColor(c1, c2 color.Color) bool { + r1, g1, b1, a1 := c1.RGBA() + r2, g2, b2, a2 := c2.RGBA() + return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 +} diff --git a/art/estflag/estflag.go b/art/estflag/estflag.go new file mode 100644 index 0000000..1c1b35b --- /dev/null +++ b/art/estflag/estflag.go @@ -0,0 +1,75 @@ +// Copyright 2017 Kaur Kuut +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package estflag + +import ( + "image" + "image/color" + + "github.com/xStrom/patriot/art" + "github.com/xStrom/patriot/painter" +) + +var x0, y0 = 75, 36 +var x1, y1 = 107, 56 + +var blue = color.RGBA64{0, 0, 60138, 65535} +var black = color.RGBA64{8738, 8738, 8738, 65535} +var white = color.RGBA64{65535, 65535, 65535, 65535} + +// Fixes any broken pixels in the provided image +func CheckImage(image image.Image) { + for x := x0; x <= x1; x++ { + for y := y0; y <= y1; y++ { + c := image.At(x, y) + + switch (y - y0) / 7 { + case 0: + if !art.SameColor(c, blue) { + painter.SetPixel(&art.Pixel{x, y, art.DarkBlue}) + } + case 1: + if !art.SameColor(c, black) { + painter.SetPixel(&art.Pixel{x, y, art.Black}) + } + case 2: + if !art.SameColor(c, white) { + painter.SetPixel(&art.Pixel{x, y, art.White}) + } + } + } + } +} + +// Fixes the provided pixel if needed +func CheckPixel(x, y, c int) { + // Make sure the pixel is even in bounds + if x >= x0 && x <= x1 && y >= y0 && y <= y1 { + switch (y - y0) / 7 { + case 0: + if c != art.DarkBlue { + painter.SetPixel(&art.Pixel{x, y, art.DarkBlue}) + } + case 1: + if c != art.Black { + painter.SetPixel(&art.Pixel{x, y, art.Black}) + } + case 2: + if c != art.White { + painter.SetPixel(&art.Pixel{x, y, art.White}) + } + } + } +} diff --git a/painter/painter.go b/painter/painter.go new file mode 100644 index 0000000..98c518d --- /dev/null +++ b/painter/painter.go @@ -0,0 +1,66 @@ +// Copyright 2017 Kaur Kuut +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package painter + +import ( + "fmt" + "sync" + "time" + + "github.com/xStrom/patriot/art" + "github.com/xStrom/patriot/sp" + "github.com/xStrom/patriot/work/shutdown" +) + +// TODO: Instead of a dumb queue use a coordinate based map so we always only draw the latest requested value + +var queue []*art.Pixel +var queueLock sync.Mutex + +func Work(wg *sync.WaitGroup) { + var p *art.Pixel + for { + shutdown.ShutdownLock.RLock() + if shutdown.Shutdown { + shutdown.ShutdownLock.RUnlock() + fmt.Printf("Shutting down painter\n") + wg.Done() + break + } + shutdown.ShutdownLock.RUnlock() + + queueLock.Lock() + if len(queue) > 0 { + p, queue = queue[0], queue[1:] + } + queueLock.Unlock() + if p != nil { + if err := sp.DrawPixel(p.X, p.Y, p.C); err != nil { + fmt.Printf("Failed drawing %v:%v to %v, because: %v", p.X, p.Y, p.C, err) + queueLock.Lock() + queue = append(queue, p) + queueLock.Unlock() + } + p = nil + } + time.Sleep(1 * time.Second) // Non-white pixels limited to 1/sec by server (white to 2.5/sec) + } +} + +func SetPixel(p *art.Pixel) { + queueLock.Lock() + queue = append(queue, p) + queueLock.Unlock() +} diff --git a/patriot.go b/patriot.go index 2499309..429309e 100644 --- a/patriot.go +++ b/patriot.go @@ -15,199 +15,56 @@ package main import ( - "bytes" "fmt" - "image" - "image/color" - "image/png" - "io/ioutil" - "net/http" + "os" + "os/signal" "sync" - "time" - "github.com/pkg/errors" -) - -const UserAgent = "Patriot/1.0 (https://github.com/xStrom/patriot)" - -const ( - White = iota - LightGray - Gray - Black - Pink - Red - Orange - Brown - Yellow - Lime - Green - Aqua - LightBlue - Blue - DarkPink - Purple + "github.com/xStrom/patriot/realtime" + "github.com/xStrom/patriot/work" + "github.com/xStrom/patriot/work/shutdown" ) func main() { - fmt.Println("Launching queue handler ...") - go executeQueue() - - fetchAndCheck() - - fmt.Println("Waiting for queue to be empty ...") - for { - queueLock.Lock() - if len(queue) > 0 { - fmt.Printf("Still have %v items in queue ..\n", len(queue)) - } else { - queueLock.Unlock() - break - } - queueLock.Unlock() - time.Sleep(10 * time.Second) - } - fmt.Println("All done!") -} - -func drawPixel(x, y, c int) error { - req, err := http.NewRequest("POST", fmt.Sprintf("https://josephg.com/sp/edit?x=%v&y=%v&c=%v", x, y, c), nil) - if err != nil { - return errors.Wrap(err, "Failed creating request") - } - req.Header.Set("User-Agent", UserAgent) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return errors.Wrap(err, "Failed performing request") - } - if resp.StatusCode != http.StatusOK { - return errors.Errorf("Got non-OK status: %v\n", resp.StatusCode) - } - if b, err := ioutil.ReadAll(resp.Body); err != nil { - return errors.Wrap(err, "Failed reading response") - } else if len(b) > 0 { - fmt.Printf("Got response:\n%v\n", string(b)) - } - fmt.Printf("Drew: %v - %v - %v\n", x, y, c) - return nil -} - -type Work struct { - X int - Y int - C int -} - -var queue []*Work -var queueLock sync.Mutex - -func executeQueue() { - var w *Work - for { - queueLock.Lock() - if len(queue) > 0 { - w, queue = queue[0], queue[1:] - } - queueLock.Unlock() - if w != nil { - if err := drawPixel(w.X, w.Y, w.C); err != nil { - fmt.Printf("Failed drawing %v:%v to %v, because: %v", w.X, w.Y, w.C, err) - queueLock.Lock() - queue = append(queue, w) + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + wg := &sync.WaitGroup{} + + fmt.Println("Launching work engine ...") + wg.Add(1) + go work.Work(wg) + + /* + fmt.Println("Waiting for queue to be empty ...") + for { + queueLock.Lock() + if len(queue) > 0 { + fmt.Printf("Still have %v items in queue ..\n", len(queue)) + } else { queueLock.Unlock() + break } - w = nil + queueLock.Unlock() + time.Sleep(10 * time.Second) } - time.Sleep(1 * time.Second) - } -} - -func addToQueue(w *Work) { - queueLock.Lock() - queue = append(queue, w) - queueLock.Unlock() -} - -func fetchAndCheck() { -start: - fmt.Printf("Fetching image ..\n") - data, err := fetchImage() - if err != nil { - fmt.Printf("Failed to fetch image: %v\n", err) - goto start - } - img, err := parseImage(data) - if err != nil { - panic("Failed to parse image") - } - checkFlag(img) -} - -func getTestImage() ([]byte, error) { - return ioutil.ReadFile("current.png") -} - -func fetchImage() ([]byte, error) { - req, err := http.NewRequest("GET", "https://josephg.com/sp/current", nil) - if err != nil { - return nil, errors.Wrap(err, "Failed creating request") - } - req.Header.Set("User-Agent", UserAgent) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, errors.Wrap(err, "Failed performing request") - } - if b, err := ioutil.ReadAll(resp.Body); err != nil { - return nil, errors.Wrap(err, "Failed reading response") - } else { - return b, nil - } -} - -func parseImage(data []byte) (image.Image, error) { - buf := bytes.NewBuffer(data) - img, err := png.Decode(buf) - if err != nil { - return nil, errors.Wrap(err, "Failed to decode image") - } - if img.Bounds().Min.X != 0 || img.Bounds().Min.Y != 0 || img.Bounds().Max.X != 1000 || img.Bounds().Max.Y != 1000 { - return nil, errors.New("Unexpected image bounds") - } - return img, nil -} + fmt.Println("All done!") + */ -func checkFlag(image image.Image) { - x0, y0 := 75, 36 - x1, y1 := 107, 56 - - blue := color.RGBA64{0, 0, 60138, 65535} - black := color.RGBA64{8738, 8738, 8738, 65535} - white := color.RGBA64{65535, 65535, 65535, 65535} - - for x := x0; x <= x1; x++ { - for y := y0; y <= y1; y++ { - c := image.At(x, y) - - switch (y - y0) / 7 { - case 0: - if !sameColor(c, blue) { - addToQueue(&Work{x, y, Blue}) - } - case 1: - if !sameColor(c, black) { - addToQueue(&Work{x, y, Black}) - } - case 2: - if !sameColor(c, white) { - addToQueue(&Work{x, y, White}) - } - } +mainLoop: + for { + select { + case <-interrupt: + fmt.Printf("interrupt -- starting shutdown sequence ..\n") + shutdown.ShutdownLock.Lock() + shutdown.Shutdown = true + shutdown.ShutdownLock.Unlock() + realtime.Shutdown() + break mainLoop } } -} -func sameColor(c1, c2 color.Color) bool { - r1, g1, b1, a1 := c1.RGBA() - r2, g2, b2, a2 := c2.RGBA() - return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 + fmt.Printf("Waiting for clean shutdown ..\n") + wg.Wait() + fmt.Printf("Clean shutdown done :>\n") } diff --git a/realtime/realtime.go b/realtime/realtime.go new file mode 100644 index 0000000..9b61f1b --- /dev/null +++ b/realtime/realtime.go @@ -0,0 +1,114 @@ +// Copyright 2017 Kaur Kuut +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package realtime + +import ( + "bytes" + "encoding/binary" + "fmt" + "net/url" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/xStrom/patriot/art/estflag" +) + +var c *websocket.Conn +var done chan struct{} + +func Realtime(wg *sync.WaitGroup, startVersion int) { + done = make(chan struct{}) + u := url.URL{Scheme: "wss", Host: "josephg.com", Path: "/sp/ws", RawQuery: fmt.Sprintf("from=%v", startVersion)} + +connect: + fmt.Printf("connecting to %s\n", u.String()) + var err error + c, _, err = websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + fmt.Printf("dial err: %v\n", err) + goto connect + } + + for { + _, message, err := c.ReadMessage() + if err != nil { + fmt.Printf("read error: %v\n", err) + break + } + if bytes.Compare(message, []byte("reload")) == 0 { + fmt.Printf("Got reload command\n") + break + } + if bytes.Compare(message, []byte("refresh")) == 0 { + fmt.Printf("Got refresh command\n") + break + } + if len(message) >= 7 { + binary.LittleEndian.Uint32(message[0:4]) // version + //fmt.Printf("Version: %v => ", version) + for i := 4; i < len(message); i += 3 { + x, y, color := decodeEdit(message[i : i+3]) // TODO: Out of bounds check + //fmt.Printf("%v:%v = %v |", x, y, color) + estflag.CheckPixel(x, y, color) + } + //fmt.Printf("\n") + } else { + fmt.Printf("recv unknown: %v\n", message) + } + } + + fmt.Printf("Close in Realtime\n") + c.Close() + close(done) + wg.Done() +} + +// returns x, y, color. +func decodeEdit(data []byte) (int, int, int) { + xx := uint(data[0]) + yx := uint(data[1]) + cy := uint(data[2]) + + x := xx | ((yx & 0x3) << 8) + y := (yx >> 2) | ((cy & 0xf) << 6) + color := cy >> 4 + + return int(x), int(y), int(color) +} + +// TODO: Currently c isn't set to nil (need sync for that anyway), so Shutdown could be called for old closed connection +func Shutdown() { + if c == nil { + return + } + // To cleanly close a connection, a client should send a close + // frame and wait for the server to close the connection. + err := c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + if err != nil { + fmt.Printf("write close error: %v\n", err) + return + } + select { + case <-done: + case <-time.After(time.Second): + } + fmt.Printf("Close in Shutdown\n") + err = c.Close() + if err != nil { + fmt.Printf("close error: %v\n", err) + } +} diff --git a/sp/sp.go b/sp/sp.go new file mode 100644 index 0000000..2d30e3d --- /dev/null +++ b/sp/sp.go @@ -0,0 +1,97 @@ +// Copyright 2017 Kaur Kuut +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sp + +import ( + "fmt" + "io/ioutil" + "net" + "net/http" + "strconv" + "time" + + "github.com/pkg/errors" +) + +const UserAgent = "Patriot/1.0 (https://github.com/xStrom/patriot)" + +var client = &http.Client{ + Transport: &http.Transport{ + Dial: (&net.Dialer{ + Timeout: 60 * time.Second, + KeepAlive: 60 * time.Second, + }).Dial, + TLSHandshakeTimeout: 60 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, + ExpectContinueTimeout: 10 * time.Second, + }, +} + +func FetchImageFromFile() ([]byte, error) { + return ioutil.ReadFile("current.png") +} + +func FetchImage() ([]byte, int, error) { + req, err := http.NewRequest("GET", "https://josephg.com/sp/current", nil) + if err != nil { + return nil, -1, errors.Wrap(err, "Failed creating request") + } + req.Header.Set("User-Agent", UserAgent) + resp, err := client.Do(req) + if err != nil { + return nil, -1, errors.Wrap(err, "Failed performing request") + } + if resp.StatusCode != http.StatusOK { + return nil, -1, errors.Errorf("Got non-OK status: %v\n", resp.StatusCode) + } + // Extract version + version := -1 + xcv := resp.Header["X-Content-Version"] + if len(xcv) > 0 { + if ver, err := strconv.ParseInt(xcv[0], 10, 64); err != nil { + return nil, -1, errors.Errorf("Failed to parse X-Content-Version: %v\n", xcv[0]) + } else { + version = int(ver) + } + } + fmt.Printf("Image version: %v\n", version) + if b, err := ioutil.ReadAll(resp.Body); err != nil { + return nil, -1, errors.Wrap(err, "Failed reading response") + } else { + return b, version, nil + } +} + +func DrawPixel(x, y, c int) error { + req, err := http.NewRequest("POST", fmt.Sprintf("https://josephg.com/sp/edit?x=%v&y=%v&c=%v", x, y, c), nil) + if err != nil { + return errors.Wrap(err, "Failed creating request") + } + req.Header.Set("User-Agent", UserAgent) + resp, err := client.Do(req) + if err != nil { + return errors.Wrap(err, "Failed performing request") + } + if resp.StatusCode != http.StatusOK { + return errors.Errorf("Got non-OK status: %v\n", resp.StatusCode) + } + if b, err := ioutil.ReadAll(resp.Body); err != nil { + return errors.Wrap(err, "Failed reading response") + } else if len(b) > 0 { + fmt.Printf("Got response:\n%v\n", string(b)) + } + fmt.Printf("Drew: %v - %v - %v\n", x, y, c) + return nil +} diff --git a/work/shutdown/shutdown.go b/work/shutdown/shutdown.go new file mode 100644 index 0000000..1686531 --- /dev/null +++ b/work/shutdown/shutdown.go @@ -0,0 +1,22 @@ +// Copyright 2017 Kaur Kuut +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shutdown + +import ( + "sync" +) + +var Shutdown = false +var ShutdownLock sync.RWMutex diff --git a/work/work.go b/work/work.go new file mode 100644 index 0000000..322033a --- /dev/null +++ b/work/work.go @@ -0,0 +1,64 @@ +// Copyright 2017 Kaur Kuut +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package work + +import ( + "fmt" + "sync" + + "github.com/xStrom/patriot/art" + "github.com/xStrom/patriot/art/estflag" + "github.com/xStrom/patriot/painter" + "github.com/xStrom/patriot/realtime" + "github.com/xStrom/patriot/sp" + "github.com/xStrom/patriot/work/shutdown" +) + +func Work(wg *sync.WaitGroup) { + fmt.Println("Launching painter ...") + wg.Add(1) + go painter.Work(wg) + + for { + shutdown.ShutdownLock.RLock() + if shutdown.Shutdown { + shutdown.ShutdownLock.RUnlock() + fmt.Printf("Shutting down work engine\n") + wg.Done() + break + } + shutdown.ShutdownLock.RUnlock() + + version := FetchImageAndCheck() + wg.Add(1) + realtime.Realtime(wg, version) + } +} + +func FetchImageAndCheck() int { +start: + fmt.Printf("Fetching image ..\n") + data, version, err := sp.FetchImage() + if err != nil { + fmt.Printf("Failed to fetch image: %v\n", err) + goto start + } + img, err := art.ParseImage(data) + if err != nil { + panic("Failed to parse image") + } + estflag.CheckImage(img) + return version +}