Add initial LED implementation for an E1.31 DMX over Ethernet controller.

This commit is contained in:
Patrick Fairbank
2018-04-15 14:59:16 -07:00
parent 8b8468f4c8
commit 4480dcc97a
5 changed files with 427 additions and 155 deletions

View File

@@ -8,6 +8,7 @@ package field
import ( import (
"fmt" "fmt"
"github.com/Team254/cheesy-arena/game" "github.com/Team254/cheesy-arena/game"
"github.com/Team254/cheesy-arena/led"
"github.com/Team254/cheesy-arena/model" "github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/partner" "github.com/Team254/cheesy-arena/partner"
"log" "log"
@@ -56,7 +57,6 @@ type Arena struct {
AllianceStationDisplays map[string]string AllianceStationDisplays map[string]string
AllianceStationDisplayScreen string AllianceStationDisplayScreen string
MuteMatchSounds bool MuteMatchSounds bool
FieldTestMode string
matchAborted bool matchAborted bool
matchStateNotifier *Notifier matchStateNotifier *Notifier
MatchTimeNotifier *Notifier MatchTimeNotifier *Notifier
@@ -71,6 +71,7 @@ type Arena struct {
AllianceSelectionNotifier *Notifier AllianceSelectionNotifier *Notifier
LowerThirdNotifier *Notifier LowerThirdNotifier *Notifier
ReloadDisplaysNotifier *Notifier ReloadDisplaysNotifier *Notifier
RedSwitchLedStrip *led.LedStrip
scale *game.Seesaw scale *game.Seesaw
redSwitch *game.Seesaw redSwitch *game.Seesaw
blueSwitch *game.Seesaw blueSwitch *game.Seesaw
@@ -143,6 +144,12 @@ func NewArena(dbPath string) (*Arena, error) {
arena.AllianceStationDisplays = make(map[string]string) arena.AllianceStationDisplays = make(map[string]string)
arena.AllianceStationDisplayScreen = "match" arena.AllianceStationDisplayScreen = "match"
// Initialize LEDs.
arena.RedSwitchLedStrip, err = led.NewLedStrip("10.0.0.30", 1, 150)
if err != nil {
return nil, err
}
return arena, nil return arena, nil
} }
@@ -374,8 +381,8 @@ func (arena *Arena) Update() {
enabled = false enabled = false
arena.AudienceDisplayScreen = "match" arena.AudienceDisplayScreen = "match"
arena.AudienceDisplayNotifier.Notify(nil) arena.AudienceDisplayNotifier.Notify(nil)
arena.FieldTestMode = ""
arena.sendGameSpecificDataPacket() arena.sendGameSpecificDataPacket()
arena.RedSwitchLedStrip.SetMode(led.WarmupMode)
if !arena.MuteMatchSounds { if !arena.MuteMatchSounds {
arena.PlaySoundNotifier.Notify("match-warmup") arena.PlaySoundNotifier.Notify("match-warmup")
} }
@@ -471,6 +478,8 @@ func (arena *Arena) Update() {
// Handle field sensors/lights/motors. // Handle field sensors/lights/motors.
arena.handlePlcInput() arena.handlePlcInput()
arena.handlePlcOutput() arena.handlePlcOutput()
arena.RedSwitchLedStrip.Update()
} }
// Loops indefinitely to track and update the arena components. // Loops indefinitely to track and update the arena components.
@@ -694,30 +703,7 @@ func (arena *Arena) handlePlcInput() {
// Writes light/motor commands to the field PLC. // Writes light/motor commands to the field PLC.
func (arena *Arena) handlePlcOutput() { func (arena *Arena) handlePlcOutput() {
if arena.FieldTestMode != "" { // TODO(patrick): Update for 2018.
// PLC output is being manually overridden.
// TODO(patrick): Update for 2018.
/*
if arena.FieldTestMode == "flash" {
blinkState := arena.Plc.GetCycleState(2, 0, 1)
arena.Plc.SetTouchpadLights([3]bool{blinkState, blinkState, blinkState},
[3]bool{blinkState, blinkState, blinkState})
} else if arena.FieldTestMode == "cycle" {
arena.Plc.SetTouchpadLights(
[3]bool{arena.Plc.GetCycleState(3, 2, 1), arena.Plc.GetCycleState(3, 1, 1), arena.Plc.GetCycleState(3, 0, 1)},
[3]bool{arena.Plc.GetCycleState(3, 0, 1), arena.Plc.GetCycleState(3, 1, 1), arena.Plc.GetCycleState(3, 2, 1)})
} else if arena.FieldTestMode == "chase" {
arena.Plc.SetTouchpadLights(
[3]bool{arena.Plc.GetCycleState(12, 2, 2), arena.Plc.GetCycleState(12, 1, 2), arena.Plc.GetCycleState(12, 0, 2)},
[3]bool{arena.Plc.GetCycleState(12, 3, 2), arena.Plc.GetCycleState(12, 4, 2), arena.Plc.GetCycleState(12, 5, 2)})
} else if arena.FieldTestMode == "slowChase" {
arena.Plc.SetTouchpadLights(
[3]bool{arena.Plc.GetCycleState(6, 2, 8), arena.Plc.GetCycleState(6, 1, 8), arena.Plc.GetCycleState(6, 0, 8)},
[3]bool{arena.Plc.GetCycleState(6, 3, 8), arena.Plc.GetCycleState(6, 4, 8), arena.Plc.GetCycleState(6, 5, 8)})
}
return
*/
}
} }
func (arena *Arena) handleEstop(station string, state bool) { func (arena *Arena) handleEstop(station string, state bool) {

343
led/led_strip.go Normal file
View File

@@ -0,0 +1,343 @@
// Copyright 2018 Team 254. All Rights Reserved.
// Author: pat@patfairbank.com (Patrick Fairbank)
//
// Represents a LED strip attached to an E1.31 sACN (DMX over Ethernet) controller.
package led
import (
"fmt"
"math/rand"
"net"
"time"
)
const (
controllerPort = 5568
sourceName = "Cheesy Arena"
packetTimeoutSec = 1
)
// LED sequence modes
const (
OffMode = iota
RedMode
GreenMode
BlueMode
WhiteMode
ChaseMode
WarmupMode
RandomMode
FadeMode
)
// Color RGB mappings
const (
red = iota
orange
yellow
green
teal
blue
purple
white
black
)
var colors = [][3]byte{
{255, 0, 0}, // Red
{255, 50, 0}, // Orange
{255, 255, 0}, // Yellow
{0, 255, 0}, // Green
{0, 100, 100}, // Teal
{0, 0, 255}, // Blue
{100, 0, 100}, // Purple
{255, 255, 255}, // White
{0, 0, 0}, // Black
}
type LedStrip struct {
Mode int
conn net.Conn
pixels [][3]byte
oldPixels [][3]byte
packet []byte
counter int
lastPacketTime time.Time
}
func NewLedStrip(controllerAddress string, dmxUniverse int, numPixels int) (*LedStrip, error) {
ledStrip := new(LedStrip)
var err error
ledStrip.conn, err = net.Dial("udp4", fmt.Sprintf("%s:%d", controllerAddress, controllerPort))
if err != nil {
return nil, err
}
ledStrip.pixels = make([][3]byte, numPixels)
ledStrip.oldPixels = make([][3]byte, numPixels)
ledStrip.packet = createBlankPacket(dmxUniverse, numPixels)
return ledStrip, nil
}
// Sets the current LED sequence mode and resets the intra-sequence counter to the beginning.
func (strip *LedStrip) SetMode(mode int) {
strip.Mode = mode
strip.counter = 0
}
// Advances the pixel values through the current sequence and sends a packet if necessary. Should be called from a timed
// loop.
func (strip *LedStrip) Update() error {
// Determine the pixel values.
switch strip.Mode {
case RedMode:
strip.updateSingleColorMode(red)
case GreenMode:
strip.updateSingleColorMode(green)
case BlueMode:
strip.updateSingleColorMode(blue)
case WhiteMode:
strip.updateSingleColorMode(white)
case ChaseMode:
strip.updateChaseMode()
case WarmupMode:
strip.updateWarmupMode()
case RandomMode:
strip.updateRandomMode()
case FadeMode:
strip.updateFadeMode()
default:
strip.updateOffMode()
}
strip.counter++
// Update non-static packet fields.
strip.packet[111]++
for i, pixel := range strip.pixels {
strip.packet[126+3*i] = pixel[0]
strip.packet[127+3*i] = pixel[1]
strip.packet[128+3*i] = pixel[2]
}
// Send the packet if it hasn't changed.
if strip.shouldSendPacket() {
_, err := strip.conn.Write(strip.packet)
if err != nil {
return err
}
strip.lastPacketTime = time.Now()
// Keep a record of the pixel values in order to detect future changes.
copy(strip.oldPixels, strip.pixels)
}
return nil
}
// Constructs the structure of an E1.31 data packet that can be re-used indefinitely by updating the pixel data and
// re-sending it.
func createBlankPacket(dmxUniverse, numPixels int) []byte {
size := 126 + 3*numPixels
packet := make([]byte, size)
// Preamble size
packet[0] = 0x00
packet[1] = 0x10
// Postamble size
packet[2] = 0x00
packet[3] = 0x00
// ACN packet identifier
packet[4] = 0x41
packet[5] = 0x53
packet[6] = 0x43
packet[7] = 0x2d
packet[8] = 0x45
packet[9] = 0x31
packet[10] = 0x2e
packet[11] = 0x31
packet[12] = 0x37
packet[13] = 0x00
packet[14] = 0x00
packet[15] = 0x00
// Root PDU length and flags
rootPduLength := size - 16
packet[16] = 0x70 | byte(rootPduLength>>8)
packet[17] = byte(rootPduLength & 0xff)
// E1.31 vector indicating that this is a data packet
packet[18] = 0x00
packet[19] = 0x00
packet[20] = 0x00
packet[21] = 0x04
// Component ID
for i, b := range []byte(sourceName) {
packet[22+i] = b
}
// Framing PDU length and flags
framingPduLength := size - 38
packet[38] = 0x70 | byte(framingPduLength>>8)
packet[39] = byte(framingPduLength & 0xff)
// E1.31 vector indicating that this is a data packet
packet[40] = 0x00
packet[41] = 0x00
packet[42] = 0x00
packet[43] = 0x02
// Source name
for i, b := range []byte(sourceName) {
packet[44+i] = b
}
// Priority
packet[108] = 100
// Universe for synchronization packets
packet[109] = 0x00
packet[110] = 0x00
// Sequence number (initial value)
packet[111] = 0x00
// Options flags
packet[112] = 0x00
// Universe
packet[113] = byte(dmxUniverse >> 8)
packet[114] = byte(dmxUniverse & 0xff)
// DMP layer PDU length
dmpPduLength := size - 115
packet[115] = 0x70 | byte(dmpPduLength>>8)
packet[116] = byte(dmpPduLength & 0xff)
// E1.31 vector indicating set property
packet[117] = 0x02
// Address and data type
packet[118] = 0xa1
// First property address
packet[119] = 0x00
packet[120] = 0x00
// Address increment
packet[121] = 0x00
packet[122] = 0x01
// Property value count
count := 1 + 3*numPixels
packet[123] = byte(count >> 8)
packet[124] = byte(count & 0xff)
// DMX start code
packet[125] = 0
return packet
}
// Returns true if the pixel data has changed or it has been too long since the last packet was sent.
func (strip *LedStrip) shouldSendPacket() bool {
for i := 0; i < len(strip.pixels); i++ {
if strip.pixels[i] != strip.oldPixels[i] {
return true
}
}
return time.Since(strip.lastPacketTime).Seconds() > packetTimeoutSec
}
func (strip *LedStrip) updateOffMode() {
for i := 0; i < len(strip.pixels); i++ {
strip.pixels[i] = colors[black]
}
}
func (strip *LedStrip) updateSingleColorMode(color int) {
for i := 0; i < len(strip.pixels); i++ {
strip.pixels[i] = colors[color]
}
}
func (strip *LedStrip) updateChaseMode() {
if strip.counter == len(colors)*len(strip.pixels) {
strip.counter = 0
}
color := strip.counter / len(strip.pixels)
pixelIndex := strip.counter % len(strip.pixels)
strip.pixels[pixelIndex] = colors[color]
}
func (strip *LedStrip) updateWarmupMode() {
endCounter := 250
if strip.counter == 0 {
// Show solid white to start.
for i := 0; i < len(strip.pixels); i++ {
strip.pixels[i] = colors[white]
}
} else if strip.counter <= endCounter {
// Build to the alliance color from each side.
numLitPixels := len(strip.pixels) / 2 * strip.counter / endCounter
for i := 0; i < numLitPixels; i++ {
strip.pixels[i] = colors[red]
strip.pixels[len(strip.pixels)-i-1] = colors[red]
}
} else {
// MaintainPrevent the counter from rolling over.
strip.counter = endCounter
}
}
func (strip *LedStrip) updateRandomMode() {
if strip.counter%10 != 0 {
return
}
for i := 0; i < len(strip.pixels); i++ {
strip.pixels[i] = colors[rand.Intn(len(colors))]
}
}
func (strip *LedStrip) updateFadeMode() {
fadeCycles := 40
holdCycles := 10
if strip.counter == 4*holdCycles+4*fadeCycles {
strip.counter = 0
}
for i := 0; i < len(strip.pixels); i++ {
if strip.counter < holdCycles {
strip.pixels[i] = colors[black]
} else if strip.counter < holdCycles+fadeCycles {
strip.pixels[i] = getFadeColor(black, red, strip.counter-holdCycles, fadeCycles)
} else if strip.counter < 2*holdCycles+fadeCycles {
strip.pixels[i] = colors[red]
} else if strip.counter < 2*holdCycles+2*fadeCycles {
strip.pixels[i] = getFadeColor(red, black, strip.counter-2*holdCycles-fadeCycles, fadeCycles)
} else if strip.counter < 3*holdCycles+2*fadeCycles {
strip.pixels[i] = colors[black]
} else if strip.counter < 3*holdCycles+3*fadeCycles {
strip.pixels[i] = getFadeColor(black, blue, strip.counter-3*holdCycles-2*fadeCycles, fadeCycles)
} else if strip.counter < 4*holdCycles+3*fadeCycles {
strip.pixels[i] = colors[blue]
} else if strip.counter < 4*holdCycles+4*fadeCycles {
strip.pixels[i] = getFadeColor(blue, black, strip.counter-4*holdCycles-3*fadeCycles, fadeCycles)
}
}
}
// Interpolates between the two colors based on the given fraction.
func getFadeColor(fromColor, toColor, numerator, denominator int) [3]byte {
from := colors[fromColor]
to := colors[toColor]
var fadeColor [3]byte
for i := 0; i < 3; i++ {
fadeColor[i] = byte(int(from[i]) + numerator*(int(to[i])-int(from[i]))/denominator)
}
return fadeColor
}

View File

@@ -7,7 +7,7 @@
{{define "title"}}Field Configuration{{end}} {{define "title"}}Field Configuration{{end}}
{{define "body"}} {{define "body"}}
<div class="row"> <div class="row">
<div class="col-lg-4 col-lg-offset-2"> <div class="col-lg-4">
<div class="well"> <div class="well">
<legend>Alliance Station Displays</legend> <legend>Alliance Station Displays</legend>
{{range $displayId, $station := .AllianceStationDisplays}} {{range $displayId, $station := .AllianceStationDisplays}}
@@ -38,82 +38,6 @@
<div class="col-lg-4"> <div class="col-lg-4">
<div class="well"> <div class="well">
<legend>PLC</legend> <legend>PLC</legend>
<form class="" action="/setup/field/test" method="POST">
<div class="form-group">
<div class="radio">
<label>
<input type="radio" name="mode" value="" onclick="this.form.submit()"
{{if eq .FieldTestMode ""}}checked{{end}}>Off
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="boiler" onclick="this.form.submit()"
{{if eq .FieldTestMode "boiler"}}checked{{end}}>Boilers On
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="rotor1" onclick="this.form.submit()"
{{if eq .FieldTestMode "rotor1"}}checked{{end}}>1 Rotor/Touchpad 1 On
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="rotor2" onclick="this.form.submit()"
{{if eq .FieldTestMode "rotor2"}}checked{{end}}>2 Rotors/Touchpad 2 On
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="rotor3" onclick="this.form.submit()"
{{if eq .FieldTestMode "rotor3"}}checked{{end}}>3 Rotors/Touchpad 3 On
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="rotor4" onclick="this.form.submit()"
{{if eq .FieldTestMode "rotor4"}}checked{{end}}>4 Rotors On
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="red" onclick="this.form.submit()"
{{if eq .FieldTestMode "red"}}checked{{end}}>All Red On
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="blue" onclick="this.form.submit()"
{{if eq .FieldTestMode "blue"}}checked{{end}}>All Blue On
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="flash" onclick="this.form.submit()"
{{if eq .FieldTestMode "flash"}}checked{{end}}>Flash Touchpads
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="cycle" onclick="this.form.submit()"
{{if eq .FieldTestMode "cycle"}}checked{{end}}>Cycle Touchpads
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="chase" onclick="this.form.submit()"
{{if eq .FieldTestMode "chase"}}checked{{end}}>Chase Touchpads
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="slowChase" onclick="this.form.submit()"
{{if eq .FieldTestMode "slowChase"}}checked{{end}}>Slow Chase Touchpads
</label>
</div>
</div>
</form>
<div class="row"> <div class="row">
<div class="col-lg-4"> <div class="col-lg-4">
<table> <table>
@@ -157,6 +81,69 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-4">
<div class="well">
<legend>LEDs</legend>
<form class="" action="/setup/field/test" method="POST">
<div class="form-group">
<div class="radio">
<label>
<input type="radio" name="mode" value="0" onclick="this.form.submit()"
{{if eq .LedMode 0}}checked{{end}}>Off
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="1" onclick="this.form.submit()"
{{if eq .LedMode 1}}checked{{end}}>Red
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="2" onclick="this.form.submit()"
{{if eq .LedMode 2}}checked{{end}}>Green
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="3" onclick="this.form.submit()"
{{if eq .LedMode 3}}checked{{end}}>Blue
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="4" onclick="this.form.submit()"
{{if eq .LedMode 4}}checked{{end}}>White
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="5" onclick="this.form.submit()"
{{if eq .LedMode 5}}checked{{end}}>Chase
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="6" onclick="this.form.submit()"
{{if eq .LedMode 6}}checked{{end}}>Warmup
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="7" onclick="this.form.submit()"
{{if eq .LedMode 7}}checked{{end}}>Random
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="mode" value="8" onclick="this.form.submit()"
{{if eq .LedMode 8}}checked{{end}}>Fade
</label>
</div>
</div>
</form>
</div>
</div>
</div> </div>
{{end}} {{end}}
{{define "script"}} {{define "script"}}

View File

@@ -9,6 +9,7 @@ import (
"github.com/Team254/cheesy-arena/field" "github.com/Team254/cheesy-arena/field"
"github.com/Team254/cheesy-arena/model" "github.com/Team254/cheesy-arena/model"
"net/http" "net/http"
"strconv"
) )
// Shows the field configuration page. // Shows the field configuration page.
@@ -25,12 +26,12 @@ func (web *Web) fieldGetHandler(w http.ResponseWriter, r *http.Request) {
data := struct { data := struct {
*model.EventSettings *model.EventSettings
AllianceStationDisplays map[string]string AllianceStationDisplays map[string]string
FieldTestMode string
Inputs []bool Inputs []bool
Counters []uint16 Counters []uint16
Coils []bool Coils []bool
}{web.arena.EventSettings, web.arena.AllianceStationDisplays, web.arena.FieldTestMode, web.arena.Plc.Inputs[:], LedMode int
web.arena.Plc.Registers[:], web.arena.Plc.Coils[:]} }{web.arena.EventSettings, web.arena.AllianceStationDisplays, web.arena.Plc.Inputs[:],
web.arena.Plc.Registers[:], web.arena.Plc.Coils[:], web.arena.RedSwitchLedStrip.Mode}
err = template.ExecuteTemplate(w, "base", data) err = template.ExecuteTemplate(w, "base", data)
if err != nil { if err != nil {
handleWebErr(w, err) handleWebErr(w, err)
@@ -72,53 +73,8 @@ func (web *Web) fieldTestPostHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// TODO(patrick): Update for 2018. mode, _ := strconv.Atoi(r.PostFormValue("mode"))
mode := r.PostFormValue("mode") web.arena.RedSwitchLedStrip.SetMode(mode)
/*
switch mode {
case "boiler":
web.arena.Plc.SetBoilerMotors(true)
web.arena.Plc.SetRotorMotors(0, 0)
web.arena.Plc.SetRotorLights(0, 0)
web.arena.Plc.SetTouchpadLights([3]bool{false, false, false}, [3]bool{false, false, false})
case "rotor1":
web.arena.Plc.SetBoilerMotors(false)
web.arena.Plc.SetRotorMotors(1, 1)
web.arena.Plc.SetRotorLights(1, 1)
web.arena.Plc.SetTouchpadLights([3]bool{true, false, false}, [3]bool{true, false, false})
case "rotor2":
web.arena.Plc.SetBoilerMotors(false)
web.arena.Plc.SetRotorMotors(2, 2)
web.arena.Plc.SetRotorLights(2, 2)
web.arena.Plc.SetTouchpadLights([3]bool{false, true, false}, [3]bool{false, true, false})
case "rotor3":
web.arena.Plc.SetBoilerMotors(false)
web.arena.Plc.SetRotorMotors(3, 3)
web.arena.Plc.SetRotorLights(2, 2)
web.arena.Plc.SetTouchpadLights([3]bool{false, false, true}, [3]bool{false, false, true})
case "rotor4":
web.arena.Plc.SetBoilerMotors(false)
web.arena.Plc.SetRotorMotors(4, 4)
web.arena.Plc.SetRotorLights(2, 2)
web.arena.Plc.SetTouchpadLights([3]bool{false, false, false}, [3]bool{false, false, false})
case "red":
web.arena.Plc.SetBoilerMotors(false)
web.arena.Plc.SetRotorMotors(4, 0)
web.arena.Plc.SetRotorLights(2, 0)
web.arena.Plc.SetTouchpadLights([3]bool{true, true, true}, [3]bool{false, false, false})
case "blue":
web.arena.Plc.SetBoilerMotors(false)
web.arena.Plc.SetRotorMotors(0, 4)
web.arena.Plc.SetRotorLights(0, 2)
web.arena.Plc.SetTouchpadLights([3]bool{false, false, false}, [3]bool{true, true, true})
default:
web.arena.Plc.SetBoilerMotors(false)
web.arena.Plc.SetRotorMotors(0, 0)
web.arena.Plc.SetRotorLights(0, 0)
web.arena.Plc.SetTouchpadLights([3]bool{false, false, false}, [3]bool{false, false, false})
}
*/
web.arena.FieldTestMode = mode
http.Redirect(w, r, "/setup/field", 303) http.Redirect(w, r, "/setup/field", 303)
} }

View File

@@ -23,7 +23,7 @@ func TestSetupField(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "12345") assert.Contains(t, recorder.Body.String(), "12345")
assert.Contains(t, recorder.Body.String(), "selected") assert.Contains(t, recorder.Body.String(), "selected")
recorder = web.postHttpResponse("/setup/field/test", "mode=rotor2") recorder = web.postHttpResponse("/setup/field/test", "mode=1")
assert.Equal(t, 303, recorder.Code) assert.Equal(t, 303, recorder.Code)
assert.Equal(t, "rotor2", web.arena.FieldTestMode) assert.Equal(t, 1, web.arena.RedSwitchLedStrip.Mode)
} }