board/board-simulator.go

494 lines
14 KiB
Go
Raw Normal View History

2024-07-31 03:14:52 +03:00
//go:build !baremetal
package board
// The generic board exists for testing locally without running on real
// hardware. This avoids potentially long edit-flash-test cycles.
import (
"bufio"
"errors"
"fmt"
"io"
"math/rand"
"os"
"os/exec"
"strings"
"sync"
"time"
"tinygo.org/x/drivers"
"tinygo.org/x/drivers/pixel"
)
const (
// The board name, as passed to TinyGo in the "-target" flag.
// This is the special name "simulator" for the simulator.
Name = "simulator"
)
// List of all devices.
//
// Support varies by board, but all boards have the following peripherals
// defined.
var (
Power = simulatedPower{}
Sensors = &simulatedSensors{}
Display = mainDisplay{}
Buttons = buttonsConfig{}
)
func init() {
AddressableLEDs = &simulatedLEDs{}
}
type simulatedPower struct{}
// Configure the battery status reader. This must be called before calling
// Status.
func (p simulatedPower) Configure() {
// Nothing to do here.
}
// Status returns the current charge status (charging, discharging) and the
// current voltage of the battery in microvolts. If the voltage is 0 it means
// there is no battery present, any other value means a value was read from the
// battery (but there may or may not be a battery attached).
//
// The percent is a rough approximation of the state of charge of the battery.
// The value -1 means the state of charge is unknown.
// It is often inaccurate while charging. It may be best to just show "charging"
// instead of a specific percentage.
func (p simulatedPower) Status() (state ChargeState, microvolts uint32, percent int8) {
// Pretend we're running on battery power and the battery is at 3.7V
// (typical lipo voltage).
actualMicrovolts := uint32(3700_000)
// Randomize the output a bit to fake ADC noise (programs should be able to
// deal with that).
microvolts = actualMicrovolts + rand.Uint32()%16384 - 8192
// Use a stable percent though, otherwise BLE battery level notifications
// will fluctuate way too much.
percent = lithumBatteryApproximation.approximate(actualMicrovolts)
return Discharging, microvolts, percent
}
type mainDisplay struct{}
type fyneScreen struct {
width int
height int
keyevents []KeyEvent
keyeventsLock sync.Mutex
touchID uint32
touches [1]TouchPoint
touchesLock sync.Mutex
}
var screen = &fyneScreen{}
// Configure returns a new display ready to draw on.
//
// Boards without a display will return nil.
func (d mainDisplay) Configure() Displayer[pixel.RGB888] {
startWindow()
screen.width = Simulator.WindowWidth
screen.height = Simulator.WindowHeight
windowSendCommand(fmt.Sprintf("display %d %d", screen.width, screen.height), nil)
return screen
}
// MaxBrightness returns the maximum brightness value. A maximum brightness
// value of 0 means that this display doesn't support changing the brightness.
func (d mainDisplay) MaxBrightness() int {
return 1
}
// SetBrightness sets brightness level of the display. It should be:
//
// 0 ≤ level ≤ MaxBrightness
//
// A value of 0 turns the backlight off entirely (but may leave the display
// running with nothing visible).
func (d mainDisplay) SetBrightness(level int) {
// Send the current and max brightness levels.
windowSendCommand(fmt.Sprintf("display-brightness %d %d", level, 1), nil)
}
// Wait until the next vertical blanking interval (vblank) interrupt is
// received. If the vblank interrupt is not available, it waits until the time
// since the previous call to WaitForVBlank is the default interval instead.
//
// The vertical blanking interval is the time between two screen refreshes. The
// vblank interrupt happens at the start of this interval, and indicates the
// period where the framebuffer is not being touched and can be updated without
// tearing.
//
// Don't use this method for timing, because vblank varies by hardware. Instead,
// use time.Now() to determine the current time and the amount of time since the
// last screen refresh.
//
// TODO: this is not a great API (it's blocking), it may change in the future.
func (d mainDisplay) WaitForVBlank(defaultInterval time.Duration) {
// I'm sure there is some SDL2 API we could use here, but I couldn't find
// one easily so just emulate it.
dummyWaitForVBlank(defaultInterval)
}
// Pixels per inch for this display.
func (d mainDisplay) PPI() int {
return Simulator.WindowPPI
}
func (d mainDisplay) ConfigureTouch() TouchInput {
startWindow()
return sdltouch{}
}
func (s *fyneScreen) Display() error {
// Nothing to do here.
return nil
}
func (s *fyneScreen) DrawBitmap(x, y int16, image pixel.Image[pixel.RGB888]) error {
displayWidth, displayHeight := s.Size()
width, height := image.Size()
if x < 0 || y < 0 || width <= 0 || height <= 0 ||
int(x)+width > int(displayWidth) || int(y)+height > int(displayHeight) {
return errors.New("board: drawing out of bounds")
}
buf := image.RawBuffer()
drawStart := time.Now()
lastUpdate := drawStart
for bufy := 0; bufy < int(height); bufy++ {
// Delay drawing a bit, to simulate a slow SPI bus.
if Simulator.WindowDrawSpeed != 0 {
now := time.Now()
expected := drawStart.Add(Simulator.WindowDrawSpeed * time.Duration(bufy*int(width)))
delay := expected.Sub(now)
if delay > 0 {
time.Sleep(delay)
now = time.Now()
}
if now.Sub(lastUpdate) > 5*time.Millisecond {
lastUpdate = now
}
}
index := (bufy * int(width)) * 3
lineBuf := buf[index : index+int(width)*3]
windowSendCommand(fmt.Sprintf("draw %d %d %d", x, int(y)+bufy, width), lineBuf)
}
return nil
}
func (s *fyneScreen) Size() (width, height int16) {
return int16(s.width), int16(s.height)
}
// Set sleep mode for this screen.
func (s *fyneScreen) Sleep(sleepEnabled bool) error {
// This is a no-op.
// TODO: use a different gray than when the backlight is set to zero, to
// indicate sleep mode.
return nil
}
var errNoRotation = errors.New("error: SetRotation isn't supported")
func (s *fyneScreen) Rotation() drivers.Rotation {
return drivers.Rotation0
}
func (s *fyneScreen) SetRotation(rotation drivers.Rotation) error {
// TODO: implement this, to be able to test rotation support.
return errNoRotation
}
func (s *fyneScreen) SetScrollArea(topFixedArea, bottomFixedArea int16) {
windowSendCommand(fmt.Sprintf("scroll-start %d %d", topFixedArea, bottomFixedArea), nil)
}
func (s *fyneScreen) SetScroll(line int16) {
windowSendCommand(fmt.Sprintf("scroll %d", line), nil)
}
func (s *fyneScreen) StopScroll() {
windowSendCommand(fmt.Sprintf("scroll-stop"), nil)
}
type sdltouch struct{}
func (s sdltouch) ReadTouch() []TouchPoint {
screen.touchesLock.Lock()
defer screen.touchesLock.Unlock()
if screen.touches[0].ID != 0 {
return screen.touches[:1]
}
return nil
}
type buttonsConfig struct{}
func (b buttonsConfig) Configure() {
}
func (b buttonsConfig) ReadInput() {
}
func (b buttonsConfig) NextEvent() KeyEvent {
screen.keyeventsLock.Lock()
defer screen.keyeventsLock.Unlock()
if len(screen.keyevents) != 0 {
event := screen.keyevents[0]
copy(screen.keyevents, screen.keyevents[1:])
screen.keyevents = screen.keyevents[:len(screen.keyevents)-1]
return event
}
return NoKeyEvent
}
type simulatedSensors struct {
configured drivers.Measurement
lock sync.Mutex
accelSource [3]float64
stepsSource uint32
accel [3]int32
steps uint32
temp int32
}
// Configure configures all sensors as specified in the which parameter.
// If there is an error, none of the sensors can be relied upon to work.
func (s *simulatedSensors) Configure(which drivers.Measurement) error {
s.configured = which
return nil
}
// Update updates the sensor values as given in the which parameter.
// All sensors in the which parameter must have been configured before, or the
// behavior may be unpredictable.
func (s *simulatedSensors) Update(which drivers.Measurement) error {
if which != s.configured&which {
// This is a bug. Don't check it on each board, but do check it in the
// simulator.
panic("asked to update sensors that weren't configured")
}
if which&drivers.Acceleration != 0 {
s.lock.Lock()
// Add some noise to the accelerometer to make the values more
// realistic.
s.accel[0] = rand.Int31n(30_000) - 15_000 + int32(s.accelSource[0]*1000_000) // x
s.accel[1] = rand.Int31n(30_000) - 15_000 + int32(s.accelSource[1]*1000_000) // y
s.accel[2] = rand.Int31n(30_000) - 15_000 + int32(s.accelSource[2]*1000_000) // z
s.steps = s.stepsSource
s.lock.Unlock()
}
if which&drivers.Temperature != 0 {
// Temperature around 20°C (with some jitter thrown in for a good
// simulation).
s.temp = 20000 + rand.Int31n(200) - 100
}
return nil
}
// Acceleration returns the last read acceleration in µg (micro-gravity). This
// includes gravity: when one of the axes is pointing straight to Earth and the
// sensor is not moving the returned value will be around 1000000 or -1000000.
//
// The accelerometer values match those used on Android. When the device is
// lying flat on a table, the Z axis is around 1g. When the device is rotated
// 90° upright, the Y axis is around 1g. When the device is then rotated 90° to
// the left (counter-clockwise), the X axis is around 1g.
//
// The simulator returns values as if the device is held upright like you'd hold
// a phone while taking a selfie.
func (s *simulatedSensors) Acceleration() (x, y, z int32) {
return s.accel[0], s.accel[1], s.accel[2]
}
// Steps returns the number of steps since the step counter started.
// The uint32 value is assumed to be large enough for all practical use cases.
//
// The value can be incremented from the simulator.
func (s *simulatedSensors) Steps() (steps uint32) {
return s.steps
}
// Temperature returns the temperature that was last read from the sensor.
// If there are multiple temperature sensors on a given board, the most accurate
// result will be returned.
//
// The simulator returns a fixed temperature, with some jitter to make it look
// more like a real-world sensor (no sensor is without noise).
func (s *simulatedSensors) Temperature() int32 {
return s.temp
}
type simulatedLEDs struct {
data []byte
}
// Initialize the addressable LEDs.
//
// The way to determine whether there are addressable LEDs on a given board, is
// to configure them and then check the length of board.AddressableLEDs.Data.
func (l *simulatedLEDs) Configure() {
startWindow()
l.data = make([]byte, Simulator.AddressableLEDs*3)
l.Update()
}
func (l *simulatedLEDs) Len() int {
return len(l.data) / 3
}
func (l *simulatedLEDs) SetRGB(i int, r, g, b uint8) {
l.data[i*3+0] = r
l.data[i*3+1] = g
l.data[i*3+2] = b
}
// Update the LEDs with the color data.
func (l *simulatedLEDs) Update() {
cmd := fmt.Sprintf("addressable-leds %d", l.Len())
windowSendCommand(cmd, l.data)
}
var (
fyneStart sync.Once
windowLock sync.Mutex
windowStdin io.WriteCloser
windowStdout io.ReadCloser
)
// Ensure the window is running in a separate process, starting it if necessary.
func startWindow() {
// Create a main loop for Fyne.
windowRunning := make(chan struct{})
fyneStart.Do(func() {
// Start the separate process that manages the window.
go func() {
cmd := exec.Command(os.Args[0], runWindowCommand)
cmd.Stderr = os.Stderr
windowStdin, _ = cmd.StdinPipe()
windowStdout, _ = cmd.StdoutPipe()
err := cmd.Start()
if err != nil {
fmt.Fprintln(os.Stdout, "could not start window process:", err)
os.Exit(1)
}
close(windowRunning)
err = cmd.Wait()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
os.Exit(1)
}
// The window was closed, so exit.
os.Exit(0)
}()
<-windowRunning
// Listen for events (keyboard/touch).
go windowListenEvents()
// Do some initialization.
windowSendCommand("title "+Simulator.WindowTitle, nil)
})
}
// Send a command to the separate process that manages the window.
// The command is a single line (without newline). The data part is optional
// binary data that can be sent with the command. The size of this binary data
// must be part of the textual command.
func windowSendCommand(command string, data []byte) {
windowLock.Lock()
defer windowLock.Unlock()
windowStdin.Write([]byte(command + "\n"))
windowStdin.Write(data)
}
// Goroutine that listens for window events like button and touch (keyboard and
// mouse).
func windowListenEvents() {
r := bufio.NewReader(windowStdout)
for {
line, err := r.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
fmt.Fprintln(os.Stderr, "failed to read I/O events from child process:", err)
}
cmd := strings.Fields(line)[0]
switch cmd {
case "keypress", "keyrelease":
// Read the key code.
var key KeyEvent
fmt.Sscanf(line, "%s %d", &cmd, &key)
if cmd == "keyrelease" {
key |= keyReleased
}
// Add the key code to the
screen.keyeventsLock.Lock()
screen.keyevents = append(screen.keyevents, key)
screen.keyeventsLock.Unlock()
case "mousedown":
// Read the event.
var x, y int16
fmt.Sscanf(line, "%s %d %d", &cmd, &x, &y)
// Update the touch state.
screen.touchesLock.Lock()
screen.touchID++
screen.touches[0] = TouchPoint{
ID: screen.touchID,
X: x,
Y: y,
}
screen.touchesLock.Unlock()
case "mouseup":
// End the current touch.
screen.touchesLock.Lock()
screen.touches[0] = TouchPoint{} // no active touch
screen.touchesLock.Unlock()
case "mousemove":
// Read the event.
var x, y int16
fmt.Sscanf(line, "%s %d %d", &cmd, &x, &y)
// Update the touch state.
screen.touchesLock.Lock()
if screen.touches[0].ID != 0 {
screen.touches[0].X = x
screen.touches[0].Y = y
}
screen.touchesLock.Unlock()
case "accel":
var x, y, z float64
fmt.Sscanf(line, "%s %f %f %f", &cmd, &x, &y, &z)
Sensors.lock.Lock()
Sensors.accelSource[0] = x
Sensors.accelSource[1] = y
Sensors.accelSource[2] = z
Sensors.lock.Unlock()
case "steps":
var n uint32
fmt.Sscanf(line, "%s %d %d", &cmd, &n)
Sensors.lock.Lock()
Sensors.stepsSource = n
Sensors.lock.Unlock()
default:
fmt.Fprintln(os.Stderr, "unknown command:", cmd)
}
}
}