mirror of https://github.com/periph/devices
Add videosink display driver (#27)
The videosink package provides a display driver implementing an HTTP request handler. Client requests get an initial snapshot of the graphics buffer and are updated further on every change. Signed-off-by: Michael Hanselmann <public@hansmi.ch>pull/30/head
parent
02831f4a67
commit
42ee8553ed
@ -0,0 +1,102 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
// Package videosink provides a display driver implementing an HTTP request
|
||||
// handler. Client requests get an initial snapshot of the graphics buffer and
|
||||
// are updated further on every change.
|
||||
//
|
||||
// The primary use case is the development of display outputs on a host
|
||||
// machine. Additionally devices with network connectivity can use this driver
|
||||
// to provide a copy of their local display via a web interface.
|
||||
//
|
||||
// The protocol used is "MJPEG" (https://en.wikipedia.org/wiki/Motion_JPEG)
|
||||
// which is often used by IP cameras. Because of its better suitability for
|
||||
// computer-drawn graphics the PNG image format is used by default. JPEG as
|
||||
// a format can be selected via Options.Format or using the "format" URL
|
||||
// parameter.
|
||||
package videosink
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"periph.io/x/conn/v3/display"
|
||||
)
|
||||
|
||||
// Options for videosink devices.
|
||||
type Options struct {
|
||||
// Width and height of the image buffer.
|
||||
Width, Height int
|
||||
|
||||
// Format specifies the image format to send to clients.
|
||||
Format ImageFormat
|
||||
|
||||
// TODO: Add options for JPEG and PNG encoder settings
|
||||
}
|
||||
|
||||
type Display struct {
|
||||
defaultFormat ImageFormat
|
||||
|
||||
mu sync.Mutex
|
||||
buffer *image.RGBA
|
||||
clients map[*client]struct{}
|
||||
snapshot map[imageConfig][]byte
|
||||
}
|
||||
|
||||
var _ display.Drawer = (*Display)(nil)
|
||||
var _ http.Handler = (*Display)(nil)
|
||||
|
||||
// New creates a new videosink device instance.
|
||||
func New(opt *Options) *Display {
|
||||
buffer := image.NewRGBA(image.Rect(0, 0, opt.Width, opt.Height))
|
||||
|
||||
// By default the alpha channel is set to full transparency. The following
|
||||
// draw operation makes it opaque.
|
||||
draw.Draw(buffer, buffer.Bounds(), image.Black, image.Point{}, draw.Src)
|
||||
|
||||
return &Display{
|
||||
buffer: buffer,
|
||||
clients: map[*client]struct{}{},
|
||||
snapshot: map[imageConfig][]byte{},
|
||||
defaultFormat: opt.Format,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the name of the device.
|
||||
func (d *Display) String() string {
|
||||
return "VideoSink"
|
||||
}
|
||||
|
||||
// Halt implements conn.Resource and terminates all running client requests
|
||||
// asynchronously.
|
||||
func (d *Display) Halt() error {
|
||||
d.mu.Lock()
|
||||
d.terminateClientsLocked()
|
||||
d.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ColorModel implements display.Drawer.
|
||||
func (d *Display) ColorModel() color.Model {
|
||||
return d.buffer.ColorModel()
|
||||
}
|
||||
|
||||
// Bounds implements display.Drawer.
|
||||
func (d *Display) Bounds() image.Rectangle {
|
||||
return d.buffer.Bounds()
|
||||
}
|
||||
|
||||
// Draw implements display.Drawer.
|
||||
func (d *Display) Draw(dstRect image.Rectangle, src image.Image, srcPts image.Point) error {
|
||||
d.mu.Lock()
|
||||
draw.Draw(d.buffer, dstRect, src, srcPts, draw.Src)
|
||||
d.bufferChangedLocked()
|
||||
d.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package videosink
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewHalt(t *testing.T) {
|
||||
d := New(&Options{Width: 100, Height: 100})
|
||||
|
||||
if err := d.Halt(); err != nil {
|
||||
t.Errorf("Halt() failed: %v", err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package videosink
|
||||
|
||||
import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var jpegOptions = jpeg.Options{
|
||||
Quality: 95,
|
||||
}
|
||||
|
||||
type pngEncoderBufferPool sync.Pool
|
||||
|
||||
func (p *pngEncoderBufferPool) Get() *png.EncoderBuffer {
|
||||
buf, _ := (*sync.Pool)(p).Get().(*png.EncoderBuffer)
|
||||
return buf
|
||||
}
|
||||
|
||||
func (p *pngEncoderBufferPool) Put(buf *png.EncoderBuffer) {
|
||||
(*sync.Pool)(p).Put(buf)
|
||||
}
|
||||
|
||||
type pngEncoderManager struct {
|
||||
once sync.Once
|
||||
pool pngEncoderBufferPool
|
||||
enc *png.Encoder
|
||||
}
|
||||
|
||||
var pngEncoder pngEncoderManager
|
||||
|
||||
// get returns a PNG encoder with a globally shared buffer pool.
|
||||
func (m *pngEncoderManager) get() *png.Encoder {
|
||||
m.once.Do(func() {
|
||||
m.enc = &png.Encoder{
|
||||
CompressionLevel: png.BestSpeed,
|
||||
BufferPool: &m.pool,
|
||||
}
|
||||
})
|
||||
|
||||
return m.enc
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package videosink
|
||||
|
||||
import "fmt"
|
||||
|
||||
type ImageFormat int
|
||||
|
||||
const (
|
||||
PNG ImageFormat = iota
|
||||
JPEG
|
||||
|
||||
// DefaultFormat is the format used when not set explicitly in options or
|
||||
// as a URL parameter.
|
||||
DefaultFormat = PNG
|
||||
)
|
||||
|
||||
func (f ImageFormat) String() string {
|
||||
switch f {
|
||||
case PNG:
|
||||
return "PNG"
|
||||
case JPEG:
|
||||
return "JPEG"
|
||||
default:
|
||||
return fmt.Sprint(int(f))
|
||||
}
|
||||
}
|
||||
|
||||
func (f ImageFormat) mimeType() string {
|
||||
switch f {
|
||||
case PNG:
|
||||
return "image/png"
|
||||
case JPEG:
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// ImageFormatFromString returns the ImageFormat value for the given format
|
||||
// abbreviation.
|
||||
func ImageFormatFromString(value string) (ImageFormat, error) {
|
||||
switch value {
|
||||
case "png":
|
||||
return PNG, nil
|
||||
case "jpg", "jpeg":
|
||||
return JPEG, nil
|
||||
}
|
||||
|
||||
return DefaultFormat, fmt.Errorf("unrecognized image format %q", value)
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package videosink
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImageFormat(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
format ImageFormat
|
||||
wantString string
|
||||
wantMimeType string
|
||||
}{
|
||||
{
|
||||
format: ImageFormat(-1),
|
||||
wantString: "-1",
|
||||
wantMimeType: "application/octet-stream",
|
||||
},
|
||||
{
|
||||
wantString: "PNG",
|
||||
wantMimeType: "image/png",
|
||||
},
|
||||
{
|
||||
format: DefaultFormat,
|
||||
wantString: "PNG",
|
||||
wantMimeType: "image/png",
|
||||
},
|
||||
{
|
||||
format: PNG,
|
||||
wantString: "PNG",
|
||||
wantMimeType: "image/png",
|
||||
},
|
||||
{
|
||||
format: JPEG,
|
||||
wantString: "JPEG",
|
||||
wantMimeType: "image/jpeg",
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprint(tc), func(t *testing.T) {
|
||||
if got := tc.format.String(); got != tc.wantString {
|
||||
t.Errorf("String() returned %q, want %q", got, tc.wantString)
|
||||
}
|
||||
|
||||
if got := tc.format.mimeType(); got != tc.wantMimeType {
|
||||
t.Errorf("mimeType() returned %q, want %q", got, tc.wantMimeType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package videosink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// bufferPool stores reusable []byte instances.
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return []byte(nil)
|
||||
},
|
||||
}
|
||||
|
||||
type imageConfig struct {
|
||||
format ImageFormat
|
||||
}
|
||||
|
||||
func (d *Display) configFromQuery(values url.Values) (imageConfig, error) {
|
||||
cfg := imageConfig{
|
||||
format: d.defaultFormat,
|
||||
}
|
||||
|
||||
if value := values.Get("format"); value != "" {
|
||||
if format, err := ImageFormatFromString(value); err != nil {
|
||||
return imageConfig{}, err
|
||||
} else {
|
||||
cfg.format = format
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
type client struct {
|
||||
refresh chan struct{}
|
||||
terminate chan struct{}
|
||||
}
|
||||
|
||||
func (d *Display) bufferChangedLocked() {
|
||||
for cfg, buffer := range d.snapshot {
|
||||
if buffer != nil {
|
||||
//lint:ignore SA6002 buffer is []byte and thus pointer-like
|
||||
bufferPool.Put(buffer)
|
||||
}
|
||||
|
||||
delete(d.snapshot, cfg)
|
||||
}
|
||||
|
||||
for c := range d.clients {
|
||||
select {
|
||||
case c.refresh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Display) terminateClientsLocked() {
|
||||
for c := range d.clients {
|
||||
select {
|
||||
case c.terminate <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Display) encodeBufferLocked(format ImageFormat) ([]byte, error) {
|
||||
buf := bytes.NewBuffer(bufferPool.Get().([]byte)[:0])
|
||||
|
||||
switch format {
|
||||
case PNG:
|
||||
if err := pngEncoder.get().Encode(buf, d.buffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case JPEG:
|
||||
if err := jpeg.Encode(buf, d.buffer, &jpegOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unhandled image format %s", format)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (d *Display) grabSnapshot(cfg imageConfig) []byte {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
encoded, ok := d.snapshot[cfg]
|
||||
if !ok {
|
||||
var err error
|
||||
|
||||
encoded, err = d.encodeBufferLocked(cfg.format)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("encoding image failed: %v", err))
|
||||
}
|
||||
d.snapshot[cfg] = encoded
|
||||
}
|
||||
|
||||
return append(bufferPool.Get().([]byte)[:0], encoded...)
|
||||
}
|
||||
|
||||
// ServeHTTP handles HTTP GET requests and sends a stream of images
|
||||
// representing the display buffer in response. The display options control the
|
||||
// default format and clients can explicitly request PNG or JPEG images using
|
||||
// the "format" parameter ("?format=png", "?format=jpeg").
|
||||
func (d *Display) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.Body.Close(); err != nil {
|
||||
log.Printf("Closing request body failed: %v", err)
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := d.configFromQuery(r.URL.Query())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pw := makePartWriter(w)
|
||||
|
||||
w.Header().Set("Content-Type",
|
||||
mime.FormatMediaType("multipart/x-mixed-replace", map[string]string{
|
||||
"boundary": pw.boundary,
|
||||
}))
|
||||
|
||||
c := &client{
|
||||
refresh: make(chan struct{}, 1),
|
||||
terminate: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.clients[c] = struct{}{}
|
||||
d.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
d.mu.Lock()
|
||||
delete(d.clients, c)
|
||||
d.mu.Unlock()
|
||||
}()
|
||||
|
||||
partHeaders := make(textproto.MIMEHeader)
|
||||
partHeaders.Set("Content-Type", mime.FormatMediaType(cfg.format.mimeType(), nil))
|
||||
partHeaders.Set("Content-Transfer-Encoding", "binary")
|
||||
|
||||
for {
|
||||
payload := d.grabSnapshot(cfg)
|
||||
err := pw.writeFrame(partHeaders, payload)
|
||||
|
||||
if payload != nil {
|
||||
//lint:ignore SA6002 buffer is []byte and thus pointer-like
|
||||
bufferPool.Put(payload)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Errors cause the request to be silently terminated. There's no
|
||||
// good way to deliver an error message to the client within an
|
||||
// image stream.
|
||||
return
|
||||
}
|
||||
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// TODO: Keepalive (send an image every N seconds)
|
||||
// TODO: Rate-limiting (don't send more than N per time unit)
|
||||
|
||||
select {
|
||||
case <-c.refresh:
|
||||
case <-c.terminate:
|
||||
return
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,275 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package videosink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
opt Options
|
||||
target string
|
||||
wantMediaType string
|
||||
|
||||
onImage func(*testing.T, image.Image)
|
||||
}
|
||||
|
||||
func (tc *testCase) validatePart(t *testing.T, part *multipart.Part) {
|
||||
t.Helper()
|
||||
|
||||
contentLength, err := strconv.ParseInt(part.Header.Get("Content-Length"), 10, 32)
|
||||
if err != nil {
|
||||
t.Errorf("Parsing Content-Length header failed: %v", err)
|
||||
}
|
||||
|
||||
decodeFunc := func(io.Reader) (image.Image, error) {
|
||||
return nil, errors.New("unknown image format")
|
||||
}
|
||||
|
||||
if mediaType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type")); err != nil {
|
||||
t.Errorf("ParseMediaType() failed: %v", err)
|
||||
} else if mediaType != tc.wantMediaType {
|
||||
t.Errorf("Got content-type %q, want %q", mediaType, tc.wantMediaType)
|
||||
} else {
|
||||
switch mediaType {
|
||||
case "image/png":
|
||||
decodeFunc = png.Decode
|
||||
case "image/jpeg":
|
||||
decodeFunc = jpeg.Decode
|
||||
}
|
||||
}
|
||||
|
||||
if content, err := ioutil.ReadAll(part); err != nil {
|
||||
t.Errorf("ReadAll() failed: %v", err)
|
||||
} else if got, want := len(content), int(contentLength); got != want {
|
||||
t.Errorf("Read %d bytes, Content-Length header is %d", got, want)
|
||||
} else if img, err := decodeFunc(bytes.NewReader(content)); err != nil {
|
||||
t.Errorf("Decoding image failed: %v", err)
|
||||
} else if got, want := img.Bounds().Size(), (image.Point{tc.opt.Width, tc.opt.Height}); got != want {
|
||||
t.Errorf("Got image size %v, want %v", got, want)
|
||||
} else if tc.onImage != nil {
|
||||
tc.onImage(t, img)
|
||||
}
|
||||
|
||||
if err := part.Close(); err != nil {
|
||||
t.Errorf("Close() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testCase) validateResponse(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
|
||||
if got, want := resp.StatusCode, http.StatusOK; got != want {
|
||||
t.Errorf("ServeHTTP() status %d, want %d", got, want)
|
||||
}
|
||||
|
||||
if mediaType, mediaParams, err := mime.ParseMediaType(resp.Header.Get("Content-Type")); err != nil {
|
||||
t.Errorf("ParseMediaType() failed: %v", err)
|
||||
} else if got, want := mediaType, "multipart/x-mixed-replace"; got != want {
|
||||
t.Errorf("Content-Type is %q, want %q", got, want)
|
||||
} else if boundary, ok := mediaParams["boundary"]; !(ok && len(boundary) > 50) {
|
||||
t.Errorf("Insufficient boundary: %s", boundary)
|
||||
} else {
|
||||
mr := multipart.NewReader(resp.Body, boundary)
|
||||
|
||||
for {
|
||||
if part, err := mr.NextPart(); errors.Is(err, io.EOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
t.Errorf("NextPart() failed: %v", err)
|
||||
} else {
|
||||
tc.validatePart(t, part)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := mr.NextPart(); !(errors.Is(err, io.EOF) || strings.HasSuffix(err.Error(), " EOF")) {
|
||||
t.Errorf("Reading beyond last part didn't fail with EOF: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartResponse(t *testing.T) {
|
||||
for _, tc := range []testCase{
|
||||
{
|
||||
name: "defaults",
|
||||
opt: Options{
|
||||
Width: 120,
|
||||
Height: 200,
|
||||
Format: DefaultFormat,
|
||||
},
|
||||
target: "/",
|
||||
wantMediaType: "image/png",
|
||||
},
|
||||
{
|
||||
name: "default PNG",
|
||||
opt: Options{
|
||||
Width: 4,
|
||||
Height: 4,
|
||||
Format: PNG,
|
||||
},
|
||||
target: "/",
|
||||
wantMediaType: "image/png",
|
||||
},
|
||||
{
|
||||
name: "default JPEG",
|
||||
opt: Options{
|
||||
Width: 200,
|
||||
Height: 100,
|
||||
Format: JPEG,
|
||||
},
|
||||
target: "/",
|
||||
wantMediaType: "image/jpeg",
|
||||
},
|
||||
{
|
||||
name: "format param PNG",
|
||||
opt: Options{
|
||||
Width: 234,
|
||||
Height: 123,
|
||||
Format: JPEG,
|
||||
},
|
||||
target: "/?format=png",
|
||||
wantMediaType: "image/png",
|
||||
},
|
||||
{
|
||||
name: "format param JPEG",
|
||||
opt: Options{
|
||||
Width: 123,
|
||||
Height: 456,
|
||||
Format: PNG,
|
||||
},
|
||||
target: "/?format=jpeg",
|
||||
wantMediaType: "image/jpeg",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
d := New(&tc.opt)
|
||||
|
||||
srv := httptest.NewServer(d)
|
||||
t.Cleanup(srv.Close)
|
||||
t.Cleanup(srv.CloseClientConnections)
|
||||
|
||||
quit := make(chan struct{})
|
||||
remaining := 10
|
||||
|
||||
tc.onImage = func(*testing.T, image.Image) {
|
||||
if remaining == 0 {
|
||||
tc.onImage = nil
|
||||
|
||||
defer close(quit)
|
||||
|
||||
if err := d.Halt(); err != nil {
|
||||
t.Errorf("Halt() failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
remaining--
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for {
|
||||
if err := d.Draw(d.Bounds(), image.Black, image.Point{}); err != nil {
|
||||
t.Errorf("Draw() failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-quit:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp, err := srv.Client().Get(srv.URL + tc.target); err != nil {
|
||||
t.Errorf("Get() failed: %v", err)
|
||||
} else {
|
||||
tc.validateResponse(t, resp)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
target string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
target: "/?format=",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
target: "/?format=bmp",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
method: http.MethodPost,
|
||||
target: "/",
|
||||
wantStatus: http.StatusMethodNotAllowed,
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprint(tc), func(t *testing.T) {
|
||||
d := New(&Options{
|
||||
Width: 16,
|
||||
Height: 16,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
srv := httptest.NewServer(d)
|
||||
t.Cleanup(srv.Close)
|
||||
t.Cleanup(srv.CloseClientConnections)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, tc.method, srv.URL+tc.target, nil)
|
||||
if err != nil {
|
||||
t.Errorf("NewRequest() failed: %v", err)
|
||||
}
|
||||
|
||||
if resp, err := srv.Client().Do(req); err != nil {
|
||||
t.Errorf("Get() failed: %v", err)
|
||||
} else if got, want := resp.StatusCode, tc.wantStatus; got != want {
|
||||
t.Errorf("Request for %s %s returned status %d (%s), want %d",
|
||||
req.Method, req.URL.String(), got, resp.Status, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package videosink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// randomBoundary generates a MIME multipart boundary compatible with RFC 2046
|
||||
// (section 5.1.1).
|
||||
func randomBoundary() string {
|
||||
var buf [34]byte
|
||||
if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return fmt.Sprintf("%x", buf[:])
|
||||
}
|
||||
|
||||
type partWriter struct {
|
||||
u io.Writer
|
||||
boundary string
|
||||
started bool
|
||||
}
|
||||
|
||||
func makePartWriter(u io.Writer) partWriter {
|
||||
return partWriter{
|
||||
u: u,
|
||||
boundary: randomBoundary(),
|
||||
}
|
||||
}
|
||||
|
||||
// writeFrame sends a single part of a MIME multipart entity, ensuring it's
|
||||
// fully written by the time the function returns.
|
||||
//
|
||||
// The caller-owned headers are modified to set a Content-Length header.
|
||||
//
|
||||
// Go has a writer for MIME multipart messages in "mime/multipart".Writer. As
|
||||
// of Go 1.17 it's not suitable for writing a neverending stream of parts where
|
||||
// each must be flushed to the client with the part-ending boundary line.
|
||||
func (w *partWriter) writeFrame(header textproto.MIMEHeader, body []byte) error {
|
||||
header.Set("Content-Length", strconv.FormatInt(int64(len(body)), 10))
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
if !w.started {
|
||||
fmt.Fprintf(&buf, "--%s\r\n", w.boundary)
|
||||
w.started = true
|
||||
}
|
||||
|
||||
for name := range header {
|
||||
for _, value := range header[name] {
|
||||
fmt.Fprintf(&buf, "%s: %s\r\n", name, value)
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
_, err := buf.WriteTo(w.u)
|
||||
if err == nil {
|
||||
_, err = io.Copy(w.u, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
_, err = fmt.Fprintf(w.u, "\r\n--%s\r\n", w.boundary)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// Copyright 2021 The Periph Authors. All rights reserved.
|
||||
// Use of this source code is governed under the Apache License, Version 2.0
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package videosink
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var boundaryRe = regexp.MustCompile(`^[a-f0-9]{60,70}$`)
|
||||
|
||||
func TestRandomBoundary(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
if got := randomBoundary(); !boundaryRe.MatchString(got) {
|
||||
t.Errorf("Boundary must match the expression %q: %s", boundaryRe.String(), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue