Merge branch 'main' into themes

custom
ailurux 1 year ago
commit 51dfb5b3e3
  1. 4
      .env.ps1
  2. 3
      .luarc.json
  3. 3
      config.ld
  4. 13
      ldoc-stubs/alerts.lua
  5. 13
      ldoc-stubs/backstack.lua
  6. 13
      ldoc-stubs/bluetooth.lua
  7. 59
      ldoc-stubs/database.lua
  8. 19
      ldoc-stubs/playback.lua
  9. 18
      ldoc-stubs/power.lua
  10. 32
      ldoc-stubs/queue.lua
  11. 35
      ldoc-stubs/types.lua
  12. 14
      ldoc-stubs/volume.lua
  13. 210
      lua/browser.lua
  14. 29
      lua/licenses.lua
  15. 12
      lua/main.lua
  16. 61
      lua/main_menu.lua
  17. 439
      lua/playing.lua
  18. 491
      lua/settings.lua
  19. 6
      luals-stubs/alerts.lua
  20. 13
      luals-stubs/backstack.lua
  21. 8
      luals-stubs/bluetooth.lua
  22. 12
      luals-stubs/controls.lua
  23. 31
      luals-stubs/database.lua
  24. 9
      luals-stubs/display.lua
  25. 1546
      luals-stubs/lvgl.lua
  26. 3
      luals-stubs/playback.lua
  27. 3
      luals-stubs/power.lua
  28. 20
      luals-stubs/queue.lua
  29. 25
      luals-stubs/screen.lua
  30. 12
      luals-stubs/time.lua
  31. 12
      luals-stubs/types.lua
  32. 7
      luals-stubs/volume.lua
  33. 43
      src/audio/audio_fsm.cpp
  34. 1
      src/audio/include/audio_events.hpp
  35. 2
      src/audio/track_queue.cpp
  36. 5
      src/battery/battery.cpp
  37. 8
      src/database/database.cpp
  38. 15
      src/drivers/display.cpp
  39. 2
      src/drivers/display_init.cpp
  40. 18
      src/drivers/gpios.cpp
  41. 3
      src/drivers/include/display_init.hpp
  42. 9
      src/drivers/include/gpios.hpp
  43. 8
      src/drivers/include/nvs.hpp
  44. 21
      src/drivers/nvs.cpp
  45. 31
      src/drivers/samd.cpp
  46. 1
      src/lua/CMakeLists.txt
  47. 2
      src/lua/bridge.cpp
  48. 15
      src/lua/include/lua_screen.hpp
  49. 75
      src/lua/lua_screen.cpp
  50. 17
      src/system_fsm/booting.cpp
  51. 1
      src/system_fsm/include/system_events.hpp
  52. 6
      src/system_fsm/system_fsm.cpp
  53. 3
      src/ui/include/screen.hpp
  54. 3
      src/ui/include/screen_lua.hpp
  55. 3
      src/ui/include/ui_fsm.hpp
  56. 3
      src/ui/screen.cpp
  57. 36
      src/ui/screen_lua.cpp
  58. 78
      src/ui/ui_fsm.cpp
  59. 2
      tools/cmake/common.cmake
  60. 202
      tools/luals-gendoc/gendoc.lua
  61. 388
      tools/luals-gendoc/json.lua

@ -0,0 +1,4 @@
$repo_dir="$(pwd)".replace("`\", "/")
$env:PROJ_PATH="$repo_dir"
$env:IDF_PATH="$repo_dir/lib/esp-idf"
. "$($env:IDF_PATH)/export.ps1"

@ -1,7 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json",
"workspace.library": ["lib/luavgl/src", "luals-stubs"],
"workspace.ignoreDir": ["ldoc-stubs"],
"workspace.library": ["luals-stubs"],
"runtime.version": "Lua 5.4",
}

@ -1,3 +0,0 @@
file = {'ldoc-stubs'}
project = "Tangara"
description = "Lua modules provided by Tangara's firmware"

@ -1,13 +0,0 @@
--- Module for showing transient popups over the current screen.
-- @module alerts
local alerts = {}
--- Shows a new alert, replacing any already visible alerts.
-- @tparam function constructor Called to create the UI for the alert. A new default root object and group will be set before calling this function.i Alerts are non-interactable; the group created for the constructor will not be granted focus.
function alerts.show(constructor) end
--- Dismisses any visible alerts, removing them from the screen.
function alerts.hide() end
return alerts

@ -1,13 +0,0 @@
--- Module for adding and removing screens from the system's backstack.
-- @module backstack
local backstack = {}
--- Pushes a new screen onto the backstack.
-- @tparam function constructor Called to create the UI for the new screen. A new default root object and group will be set before calling this function. The function provided should return a table holding any bindings used by this screen; the returned value is retained so long as this screen is present in the backstack.
function backstack.push(constructor) end
--- Removes the currently active screen, and instead shows the screen underneath it on the backstack. Does nothing if this is the only existing screen.
function backstack.pop() end
return backstack

@ -1,13 +0,0 @@
--- Properties and functions for handling Bluetooth connectivity
-- @module bluetooth
local bluetooth = {}
--- Whether or not the Bluetooth stack is currently enabled. This property is writeable, and can be used to enable or disable Bluetooth.
-- @see types.Property
bluetooth.enabled = types.Property
--- Whether or not there is an active connection to another Bluetooth device.
-- @see types.Property
bluetooth.connected = types.Property
return bluetooth

@ -1,59 +0,0 @@
--- Module for accessing and updating data about the user's library of tracks.
-- @module database
local database = {}
--- Returns a list of all indexes in the database.
-- @treturn Array(Index)
function database.indexes() end
--- An iterator is a userdata type that behaves like an ordinary Lua iterator.
-- @type Iterator
local Iterator = {}
--- A TrackId is a unique identifier, representing a playable track in the
--- user's library.
-- @type TrackId
local TrackId = {}
--- A record is an item within an Index, representing some value at a specific
--- depth.
-- @type Record
local Record = {}
--- Gets the human-readable text representing this record. The `__tostring`
--- metatable function is an alias of this function.
-- @treturn string
function Record:title() end
--- Returns the value that this record represents. This may be either a track
--- id, for records which uniquely identify a track, or it may be a new
--- Iterator representing the next level of depth for the current index.
---
--- For example, each Record in the "All Albums" index corresponds to an entire
--- album of tracks; the 'contents' of such a Record is an iterator returning
--- each track in the album represented by the Record. The contents of each of
--- the returned 'track' Records would be a full Track, as there is no further
--- disambiguation needed.
-- @treturn TrackId|Iterator(Record)
function Record:contents() end
--- An index is heirarchical, sorted, view of the tracks within the database.
--- For example, the 'All Albums' index contains, first, a sorted list of every
--- album name in the library. Then, at the second level of the index, a sorted
--- list of every track within each album.
-- @type Index
local Index = {}
--- Gets the human-readable name of this index. This is typically something
--- like "All Albums", or "Albums by Artist". The `__tostring` metatable
--- function is an alias of this function.
-- @treturn string
function Index:name() end
--- Returns a new iterator that can be used to access every record within the
--- first level of this index.
-- @treturn Iterator(Record)
function Index:iter() end
return database

@ -1,19 +0,0 @@
--- Properties for interacting with the audio playback system
-- @module playback
local playback = {}
--- Whether or not any audio is *allowed* to be played. If there is a current track, then this is essentially an indicator of whether playback is paused or unpaused.
-- @see types.Property
playback.playing = types.Property
--- Rich information about the currently playing track.
-- @see types.Property
-- @see types.Track
playback.track = types.Property
--- The current playback position within the current track, in seconds.
-- @see types.Property
playback.position = types.Property
return playback

@ -1,18 +0,0 @@
--- Properties and functions that deal with the device's battery and power state
-- @module power
local power = {}
--- The battery's current charge as a percentage
-- @see types.Property
power.battery_pct = types.Property
--- The battery's current voltage, in millivolts.
-- @see types.Property
power.battery_millivolts = types.Property
--- Whether or not the device is currently receiving external power
-- @see types.Property
power.plugged_in = types.Property
return power

@ -1,32 +0,0 @@
--- Properties and functions for inspecting and manipulating the track playback queue
-- @module queue
local queue = {}
--- The index in the queue of the currently playing track. This may be zero if the queue is empty.
-- @see types.Property
queue.position = types.Property
--- The total number of tracks in the queue, including tracks which have already been played.
-- @see types.Property
queue.size = types.Property
--- Determines whether or not the queue will be restarted after the final track is played.
-- @see types.Property
queue.replay = types.Property
-- Determines whether or not the current track will repeat indefinitely
-- @see types.Property
queue.repeat_track = types.Property
--- Determines whether, when progressing to the next track in the queue, the next track will be chosen randomly. The random selection algorithm used is a Miller Shuffle, which guarantees that no repeat selections will be made until every item in the queue has been played.
-- @see types.Property
queue.random = types.Property
--- Moves forward in the play queue, looping back around to the beginning if repeat is on.
function queue.next() end
--- Moves backward in the play queue, looping back around to the end if repeat is on.
function queue.previous() end
return queue

@ -1,35 +0,0 @@
--- Userdata-based types used throughout the rest of the API. These types are
--- not generally constructable within Lua code.
-- @module types
local types = {}
--- A observable value, owned by the C++ firmware.
-- @type Property
types.Property = {}
--- Gets the current value
-- @return The property's current value.
function Property:get() end
--- Sets a new value. Not all properties may be set from within Lua code. For
--- example, it makes little sense to attempt to override the current battery
--- level.
-- @param val The new value. This should generally be of the same type as the existing value.
-- @return true if the new value was applied, or false if the backing C++ code rejected the new value (e.g. if it was out of range, or the wrong type).
function Property:set(val) end
--- Invokes the given function once immediately with the current value, and then again whenever the value changes.
--- The function is invoked for *all* changes; both from the underlying C++ data, and from calls to `set` (if this is a Lua-writeable property).
--- The binding will be active **only** so long as the given function remains in scope.
-- @param fn callback function to apply property values. Must accept one argument; the updated value.
-- @return fn, for more ergonmic use with anonymous closures.
function Property:bind(fn) end
--- Table containing information about a track. Most fields are optional.
-- @type Track
types.Track = {}
--- The title of the track, or the filename if no title is available.
types.Track.title = ""
return Property

@ -1,14 +0,0 @@
--- Module for interacting with playback volume. The Bluetooth and wired outputs store their current volume separately; this API only allows interacting with the volume of the currently used output device.
-- @module volume
local volume = {}
--- The current volume as a percentage of the current volume limit.
-- @see types.Property
volume.current_pct = types.Property
--- The current volume in terms of decibels relative to line level.
-- @see types.Property
volume.current_db = types.Property
return volume

@ -7,30 +7,11 @@ local playing = require("playing")
local styles = require("styles")
local playback = require("playback")
local theme = require("theme")
local screen = require("screen")
local browser = {}
function browser.create(opts)
local screen = {}
screen.root = lvgl.Object(nil, {
flex = {
flex_direction = "column",
flex_wrap = "wrap",
justify_content = "flex-start",
align_items = "flex-start",
align_content = "flex-start",
},
w = lvgl.HOR_RES(),
h = lvgl.VER_RES(),
})
screen.root:center()
screen.status_bar = widgets.StatusBar(screen.root, {
title = opts.title,
})
if opts.breadcrumb then
local header = screen.root:Object {
return screen:new {
createUi = function(self)
self.root = lvgl.Object(nil, {
flex = {
flex_direction = "column",
flex_wrap = "wrap",
@ -39,101 +20,114 @@ function browser.create(opts)
align_content = "flex-start",
},
w = lvgl.HOR_RES(),
h = lvgl.SIZE_CONTENT,
pad_left = 4,
pad_right = 4,
pad_bottom = 2,
bg_opa = lvgl.OPA(100),
scrollbar_mode = lvgl.SCROLLBAR_MODE.OFF,
}
h = lvgl.VER_RES(),
})
self.root:center()
self.status_bar = widgets.StatusBar(self.root, {
title = self.title,
})
if self.breadcrumb then
local header = self.root:Object {
flex = {
flex_direction = "column",
flex_wrap = "wrap",
justify_content = "flex-start",
align_items = "flex-start",
align_content = "flex-start",
},
w = lvgl.HOR_RES(),
h = lvgl.SIZE_CONTENT,
pad_left = 4,
pad_right = 4,
pad_bottom = 2,
bg_opa = lvgl.OPA(100),
scrollbar_mode = lvgl.SCROLLBAR_MODE.OFF,
}
theme.set_style(header, "header")
header:Label {
text = opts.breadcrumb,
text_font = font.fusion_10,
}
header:Label {
text = self.breadcrumb,
text_font = font.fusion_10,
}
local buttons = header:Object({
flex = {
flex_direction = "row",
flex_wrap = "wrap",
justify_content = "flex-end",
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_column = 4,
})
local original_iterator = opts.iterator:clone()
local enqueue = widgets.IconBtn(buttons, "//lua/img/enqueue.png", "Enqueue")
enqueue:onClicked(function()
queue.add(original_iterator)
playback.playing:set(true)
end)
-- enqueue:add_flag(lvgl.FLAG.HIDDEN)
local play = widgets.IconBtn(buttons, "//lua/img/play_small.png", "Play")
play:onClicked(function()
queue.clear()
queue.add(original_iterator)
playback.playing:set(true)
backstack.push(playing)
local buttons = header:Object({
flex = {
flex_direction = "row",
flex_wrap = "wrap",
justify_content = "flex-end",
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_column = 4,
})
local original_iterator = self.iterator:clone()
local enqueue = widgets.IconBtn(buttons, "//lua/img/enqueue.png", "Enqueue")
enqueue:onClicked(function()
queue.add(original_iterator)
playback.playing:set(true)
end)
-- enqueue:add_flag(lvgl.FLAG.HIDDEN)
local play = widgets.IconBtn(buttons, "//lua/img/play_small.png", "Play")
play:onClicked(function()
queue.clear()
queue.add(original_iterator)
playback.playing:set(true)
backstack.push(playing)
end
)
end
)
end
screen.list = lvgl.List(screen.root, {
w = lvgl.PCT(100),
h = lvgl.PCT(100),
flex_grow = 1,
scrollbar_mode = lvgl.SCROLLBAR_MODE.OFF,
})
self.list = lvgl.List(self.root, {
w = lvgl.PCT(100),
h = lvgl.PCT(100),
flex_grow = 1,
scrollbar_mode = lvgl.SCROLLBAR_MODE.OFF,
})
local back = screen.list:add_btn(nil, "< Back")
back:onClicked(backstack.pop)
back:add_style(styles.list_item)
local back = self.list:add_btn(nil, "< Back")
back:onClicked(backstack.pop)
back:add_style(styles.list_item)
screen.focused_item = 0
screen.last_item = 0
screen.add_item = function(item)
if not item then return end
screen.last_item = screen.last_item + 1
local this_item = screen.last_item
local btn = screen.list:add_btn(nil, tostring(item))
btn:onClicked(function()
local contents = item:contents()
if type(contents) == "userdata" then
backstack.push(function()
return browser.create({
title = opts.title,
self.focused_item = 0
self.last_item = 0
self.add_item = function(item)
if not item then return end
self.last_item = self.last_item + 1
local this_item = self.last_item
local btn = self.list:add_btn(nil, tostring(item))
btn:onClicked(function()
local contents = item:contents()
if type(contents) == "userdata" then
backstack.push(require("browser"):new {
title = self.title,
iterator = contents,
breadcrumb = tostring(item),
})
end)
else
queue.clear()
queue.add(contents)
playback.playing:set(true)
backstack.push(playing)
end
end)
btn:onevent(lvgl.EVENT.FOCUSED, function()
screen.focused_item = this_item
if screen.last_item - 5 < this_item then
screen.add_item(opts.iterator())
end
end)
btn:add_style(styles.list_item)
end
else
queue.clear()
queue.add(contents)
playback.playing:set(true)
backstack.push(playing:new())
end
end)
btn:onevent(lvgl.EVENT.FOCUSED, function()
self.focused_item = this_item
if self.last_item - 5 < this_item then
self.add_item(self.iterator())
end
end)
btn:add_style(styles.list_item)
end
for _ = 1, 8 do
local val = opts.iterator()
if not val then break end
screen.add_item(val)
for _ = 1, 8 do
local val = self.iterator()
if not val then break end
self.add_item(val)
end
end
return screen
end
return browser.create
}

@ -2,20 +2,23 @@ local backstack = require("backstack")
local widgets = require("widgets")
local font = require("font")
local styles = require("styles")
local screen = require("screen")
local function show_license(text)
backstack.push(function()
local screen = widgets.MenuScreen {
show_back = true,
title = "Licenses",
}
screen.root:Label {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
text_font = font.fusion_10,
text = text,
}
end)
backstack.push(screen:new {
createUi = function(self)
self.menu = widgets.MenuScreen {
show_back = true,
title = "Licenses",
}
self.menu.root:Label {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
text_font = font.fusion_10,
text = text,
}
end
})
end
local function gpl(copyright)
@ -175,4 +178,6 @@ return function()
library("tremor", "bsd", function()
xiphbsd("Copyright (c) 2002, Xiph.org Foundation")
end)
return menu
end

@ -33,6 +33,18 @@ GLOBAL_BINDINGS = {
container:center()
end)
end),
-- When the device has been locked for a while, default to showing the now
-- playing screen after unlocking.
controls.lock_switch:bind(function(locked)
if locked then
lock_time = time.ticks()
elseif time.ticks() - lock_time > 8000 then
local queue = require("queue")
if queue.size:get() > 0 then
require("playing"):pushIfNotShown()
end
end
end),
}
local theme_dark = require("theme_dark")

@ -5,41 +5,42 @@ local backstack = require("backstack")
local browser = require("browser")
local playing = require("playing")
local styles = require("styles")
local screen = require("screen")
return function()
local menu = widgets.MenuScreen({})
return screen:new {
createUi = function()
local menu = widgets.MenuScreen({})
menu.list = lvgl.List(menu.root, {
w = lvgl.PCT(100),
h = lvgl.PCT(100),
flex_grow = 1,
})
menu.list = lvgl.List(menu.root, {
w = lvgl.PCT(100),
h = lvgl.PCT(100),
flex_grow = 1,
})
local now_playing = menu.list:add_btn(nil, "Now Playing")
now_playing:onClicked(function()
backstack.push(playing)
end)
now_playing:add_style(styles.list_item)
local now_playing = menu.list:add_btn(nil, "Now Playing")
now_playing:onClicked(function()
backstack.push(playing:new())
end)
now_playing:add_style(styles.list_item)
local indexes = database.indexes()
for _, idx in ipairs(indexes) do
local btn = menu.list:add_btn(nil, tostring(idx))
btn:onClicked(function()
backstack.push(function()
return browser {
local indexes = database.indexes()
for _, idx in ipairs(indexes) do
local btn = menu.list:add_btn(nil, tostring(idx))
btn:onClicked(function()
backstack.push(browser:new {
title = tostring(idx),
iterator = idx:iter()
}
iterator = idx:iter(),
})
end)
end)
btn:add_style(styles.list_item)
end
btn:add_style(styles.list_item)
end
local settings = menu.list:add_btn(nil, "Settings")
settings:onClicked(function()
backstack.push(require("settings").root)
end)
settings:add_style(styles.list_item)
local settings = menu.list:add_btn(nil, "Settings")
settings:onClicked(function()
backstack.push(require("settings"):new())
end)
settings:add_style(styles.list_item)
return menu
end
return menu
end,
}

@ -4,6 +4,7 @@ local backstack = require("backstack")
local font = require("font")
local playback = require("playback")
local queue = require("queue")
local screen = require("screen")
local img = {
play = "//lua/img/play.png",
@ -18,219 +19,227 @@ local img = {
repeat_disabled = "//lua/img/repeat_disabled.png",
}
return function(opts)
local screen = {}
screen.root = lvgl.Object(nil, {
flex = {
flex_direction = "column",
flex_wrap = "wrap",
justify_content = "center",
align_items = "center",
align_content = "center",
},
w = lvgl.HOR_RES(),
h = lvgl.VER_RES(),
})
screen.root:center()
screen.status_bar = widgets.StatusBar(screen.root, {
back_cb = backstack.pop,
transparent_bg = true,
})
local info = screen.root:Object {
flex = {
flex_direction = "column",
flex_wrap = "wrap",
justify_content = "center",
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
flex_grow = 1,
}
local artist = info:Label {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
text = "",
text_font = font.fusion_10,
text_align = 2,
}
local title = info:Label {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
text = "",
text_align = 2,
}
local playlist = screen.root:Object {
flex = {
flex_direction = "row",
justify_content = "center",
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
}
playlist:Object({ w = 3, h = 1 }) -- spacer
local cur_time = playlist:Label {
w = lvgl.SIZE_CONTENT,
h = lvgl.SIZE_CONTENT,
text = "",
text_font = font.fusion_10,
}
playlist:Object({ flex_grow = 1, h = 1 }) -- spacer
local playlist_pos = playlist:Label {
text = "",
text_font = font.fusion_10,
}
playlist:Label {
text = "/",
text_font = font.fusion_10,
}
local playlist_total = playlist:Label {
text = "",
text_font = font.fusion_10,
}
playlist:Object({ flex_grow = 1, h = 1 }) -- spacer
local end_time = playlist:Label {
w = lvgl.SIZE_CONTENT,
h = lvgl.SIZE_CONTENT,
align = lvgl.ALIGN.RIGHT_MID,
text = "",
text_font = font.fusion_10,
}
playlist:Object({ w = 3, h = 1 }) -- spacer
local scrubber = screen.root:Slider {
w = lvgl.PCT(100),
h = 5,
range = { min = 0, max = 100 },
value = 0,
}
scrubber:onevent(lvgl.EVENT.RELEASED, function()
playback.position:set(scrubber:value())
end)
local controls = screen.root:Object {
flex = {
flex_direction = "row",
justify_content = "center",
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_column = 8,
pad_all = 2,
}
controls:Object({ flex_grow = 1, h = 1 }) -- spacer
local repeat_btn = controls:Button {}
repeat_btn:onClicked(function()
queue.repeat_track:set(not queue.repeat_track:get())
end)
local repeat_img = repeat_btn:Image { src = img.repeat_enabled }
local prev_btn = controls:Button {}
prev_btn:onClicked(queue.previous)
local prev_img = prev_btn:Image { src = img.prev_disabled }
local play_pause_btn = controls:Button {}
play_pause_btn:onClicked(function()
playback.playing:set(not playback.playing:get())
end)
play_pause_btn:focus()
local play_pause_img = play_pause_btn:Image { src = img.pause }
local next_btn = controls:Button {}
next_btn:onClicked(queue.next)
local next_img = next_btn:Image { src = img.next_disabled }
local shuffle_btn = controls:Button {}
shuffle_btn:onClicked(function()
queue.random:set(not queue.random:get())
end)
local shuffle_img = shuffle_btn:Image { src = img.shuffle }
controls:Object({ flex_grow = 1, h = 1 }) -- spacer
local format_time = function(time)
return string.format("%d:%02d", time // 60, time % 60)
local is_now_playing_shown = false
return screen:new {
createUi = function(self)
self.root = lvgl.Object(nil, {
flex = {
flex_direction = "column",
flex_wrap = "wrap",
justify_content = "center",
align_items = "center",
align_content = "center",
},
w = lvgl.HOR_RES(),
h = lvgl.VER_RES(),
})
self.root:center()
self.status_bar = widgets.StatusBar(self.root, {
back_cb = backstack.pop,
transparent_bg = true,
})
local info = self.root:Object {
flex = {
flex_direction = "column",
flex_wrap = "wrap",
justify_content = "center",
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
flex_grow = 1,
}
local artist = info:Label {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
text = "",
text_font = font.fusion_10,
text_align = 2,
}
local title = info:Label {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
text = "",
text_align = 2,
}
local playlist = self.root:Object {
flex = {
flex_direction = "row",
justify_content = "center",
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
}
playlist:Object({ w = 3, h = 1 }) -- spacer
local cur_time = playlist:Label {
w = lvgl.SIZE_CONTENT,
h = lvgl.SIZE_CONTENT,
text = "",
text_font = font.fusion_10,
}
playlist:Object({ flex_grow = 1, h = 1 }) -- spacer
local playlist_pos = playlist:Label {
text = "",
text_font = font.fusion_10,
}
playlist:Label {
text = "/",
text_font = font.fusion_10,
}
local playlist_total = playlist:Label {
text = "",
text_font = font.fusion_10,
}
playlist:Object({ flex_grow = 1, h = 1 }) -- spacer
local end_time = playlist:Label {
w = lvgl.SIZE_CONTENT,
h = lvgl.SIZE_CONTENT,
align = lvgl.ALIGN.RIGHT_MID,
text = "",
text_font = font.fusion_10,
}
playlist:Object({ w = 3, h = 1 }) -- spacer
local scrubber = self.root:Slider {
w = lvgl.PCT(100),
h = 5,
range = { min = 0, max = 100 },
value = 0,
}
scrubber:onevent(lvgl.EVENT.RELEASED, function()
playback.position:set(scrubber:value())
end)
local controls = self.root:Object {
flex = {
flex_direction = "row",
justify_content = "center",
align_items = "center",
align_content = "center",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_column = 8,
pad_all = 2,
}
controls:Object({ flex_grow = 1, h = 1 }) -- spacer
local repeat_btn = controls:Button {}
repeat_btn:onClicked(function()
queue.repeat_track:set(not queue.repeat_track:get())
end)
local repeat_img = repeat_btn:Image { src = img.repeat_enabled }
local prev_btn = controls:Button {}
prev_btn:onClicked(queue.previous)
local prev_img = prev_btn:Image { src = img.prev_disabled }
local play_pause_btn = controls:Button {}
play_pause_btn:onClicked(function()
playback.playing:set(not playback.playing:get())
end)
play_pause_btn:focus()
local play_pause_img = play_pause_btn:Image { src = img.pause }
local next_btn = controls:Button {}
next_btn:onClicked(queue.next)
local next_img = next_btn:Image { src = img.next_disabled }
local shuffle_btn = controls:Button {}
shuffle_btn:onClicked(function()
queue.random:set(not queue.random:get())
end)
local shuffle_img = shuffle_btn:Image { src = img.shuffle }
controls:Object({ flex_grow = 1, h = 1 }) -- spacer
local format_time = function(time)
return string.format("%d:%02d", time // 60, time % 60)
end
self.bindings = {
playback.playing:bind(function(playing)
if playing then
play_pause_img:set_src(img.pause)
else
play_pause_img:set_src(img.play)
end
end),
playback.position:bind(function(pos)
if not pos then return end
cur_time:set {
text = format_time(pos)
}
if not scrubber:is_dragged() then
scrubber:set { value = pos }
end
end),
playback.track:bind(function(track)
if not track then return end
end_time:set {
text = format_time(track.duration)
}
title:set { text = track.title }
artist:set { text = track.artist }
scrubber:set {
range = { min = 0, max = track.duration }
}
end),
queue.position:bind(function(pos)
if not pos then return end
playlist_pos:set { text = tostring(pos) }
next_img:set_src(
pos < queue.size:get() and img.next or img.next_disabled
)
prev_img:set_src(
pos > 1 and img.prev or img.prev_disabled
)
end),
queue.random:bind(function(shuffling)
if shuffling then
shuffle_img:set_src(img.shuffle)
else
shuffle_img:set_src(img.shuffle_disabled)
end
end),
queue.repeat_track:bind(function(en)
if en then
repeat_img:set_src(img.repeat_enabled)
else
repeat_img:set_src(img.repeat_disabled)
end
end),
queue.size:bind(function(num)
if not num then return end
playlist_total:set { text = tostring(num) }
end),
}
end,
onShown = function() is_now_playing_shown = true end,
onHidden = function() is_now_playing_shown = false end,
pushIfNotShown = function(self)
if not is_now_playing_shown then
backstack.push(self:new())
end
end
screen.bindings = {
playback.playing:bind(function(playing)
if playing then
play_pause_img:set_src(img.pause)
else
play_pause_img:set_src(img.play)
end
end),
playback.position:bind(function(pos)
if not pos then return end
cur_time:set {
text = format_time(pos)
}
if not scrubber:is_dragged() then
scrubber:set { value = pos }
end
end),
playback.track:bind(function(track)
if not track then return end
end_time:set {
text = format_time(track.duration)
}
title:set { text = track.title }
artist:set { text = track.artist }
scrubber:set {
range = { min = 0, max = track.duration }
}
end),
queue.position:bind(function(pos)
if not pos then return end
playlist_pos:set { text = tostring(pos) }
next_img:set_src(
pos < queue.size:get() and img.next or img.next_disabled
)
prev_img:set_src(
pos > 1 and img.prev or img.prev_disabled
)
end),
queue.random:bind(function(shuffling)
if shuffling then
shuffle_img:set_src(img.shuffle)
else
shuffle_img:set_src(img.shuffle_disabled)
end
end),
queue.repeat_track:bind(function(en)
if en then
repeat_img:set_src(img.repeat_enabled)
else
repeat_img:set_src(img.repeat_disabled)
end
end),
queue.size:bind(function(num)
if not num then return end
playlist_total:set { text = tostring(num) }
end),
}
return screen
end
}

@ -8,8 +8,7 @@ local controls = require("controls")
local bluetooth = require("bluetooth")
local theme = require("theme")
local database = require("database")
local settings = {}
local screen = require("screen")
local function SettingsScreen(title)
local menu = widgets.MenuScreen {
@ -32,158 +31,161 @@ local function SettingsScreen(title)
return menu
end
function settings.bluetooth()
local menu = SettingsScreen("Bluetooth")
local BluetoothSettings = screen:new {
createUi = function(self)
self.menu = SettingsScreen("Bluetooth")
local enable_container = self.menu.content:Object {
flex = {
flex_direction = "row",
justify_content = "flex-start",
align_items = "content",
align_content = "flex-start",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_bottom = 1,
}
enable_container:Label { text = "Enable", flex_grow = 1 }
local enable_sw = enable_container:Switch {}
enable_sw:onevent(lvgl.EVENT.VALUE_CHANGED, function()
local enabled = enable_sw:enabled()
bluetooth.enabled:set(enabled)
end)
local enable_container = menu.content:Object {
flex = {
flex_direction = "row",
justify_content = "flex-start",
align_items = "content",
align_content = "flex-start",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_bottom = 1,
}
enable_container:Label { text = "Enable", flex_grow = 1 }
local enable_sw = enable_container:Switch {}
enable_sw:onevent(lvgl.EVENT.VALUE_CHANGED, function()
local enabled = enable_sw:enabled()
bluetooth.enabled:set(enabled)
end)
theme.set_style(menu.content:Label {
theme.set_style(self.menu.content:Label {
text = "Paired Device",
pad_bottom = 1,
}, "settings_title")
local paired_container = menu.content:Object {
flex = {
flex_direction = "row",
justify_content = "flex-start",
align_items = "flex-start",
align_content = "flex-start",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_bottom = 2,
}
local paired_container = self.menu.content:Object {
flex = {
flex_direction = "row",
justify_content = "flex-start",
align_items = "flex-start",
align_content = "flex-start",
},
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
pad_bottom = 2,
}
local paired_device = paired_container:Label {
flex_grow = 1,
}
local clear_paired = paired_container:Button {}
clear_paired:Label { text = "x" }
clear_paired:onClicked(function()
bluetooth.paired_device:set()
end)
local paired_device = paired_container:Label {
flex_grow = 1,
}
local clear_paired = paired_container:Button {}
clear_paired:Label { text = "x" }
clear_paired:onClicked(function()
bluetooth.paired_device:set()
end)
theme.set_style(menu.content:Label {
theme.set_style(self.menu.content:Label {
text = "Nearby Devices",
pad_bottom = 1,
}, "settings_title")
local devices = menu.content:List {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
}
local devices = self.menu.content:List {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
}
menu.bindings = {
bluetooth.enabled:bind(function(en)
if en then
enable_sw:add_state(lvgl.STATE.CHECKED)
else
enable_sw:clear_state(lvgl.STATE.CHECKED)
end
end),
bluetooth.paired_device:bind(function(device)
if device then
paired_device:set { text = device.name }
clear_paired:clear_flag(lvgl.FLAG.HIDDEN)
else
paired_device:set { text = "None" }
clear_paired:add_flag(lvgl.FLAG.HIDDEN)
end
end),
bluetooth.devices:bind(function(devs)
devices:clean()
for _, dev in pairs(devs) do
devices:add_btn(nil, dev.name):onClicked(function()
bluetooth.paired_device:set(dev)
end)
end
end)
}
end
self.bindings = {
bluetooth.enabled:bind(function(en)
if en then
enable_sw:add_state(lvgl.STATE.CHECKED)
else
enable_sw:clear_state(lvgl.STATE.CHECKED)
end
end),
bluetooth.paired_device:bind(function(device)
if device then
paired_device:set { text = device.name }
clear_paired:clear_flag(lvgl.FLAG.HIDDEN)
else
paired_device:set { text = "None" }
clear_paired:add_flag(lvgl.FLAG.HIDDEN)
end
end),
bluetooth.devices:bind(function(devs)
devices:clean()
for _, dev in pairs(devs) do
devices:add_btn(nil, dev.name):onClicked(function()
bluetooth.paired_device:set(dev)
end)
end
end)
}
end
}
function settings.headphones()
local menu = SettingsScreen("Headphones")
local HeadphonesSettings = screen:new {
createUi = function(self)
self.menu = SettingsScreen("Headphones")
theme.set_style(menu.content:Label {
theme.set_style(self.menu.content:Label {
text = "Maxiumum volume limit",
}, "settings_title")
local volume_chooser = menu.content:Dropdown {
options = "Line Level (-10 dB)\nCD Level (+6 dB)\nMaximum (+10dB)",
selected = 1,
}
local limits = { -10, 6, 10 }
volume_chooser:onevent(lvgl.EVENT.VALUE_CHANGED, function()
-- luavgl dropdown binding uses 0-based indexing :(
local selection = volume_chooser:get('selected') + 1
volume.limit_db:set(limits[selection])
end)
theme.set_style(menu.content:Label {
local volume_chooser = self.menu.content:Dropdown {
options = "Line Level (-10 dB)\nCD Level (+6 dB)\nMaximum (+10dB)",
selected = 1,
}
local limits = { -10, 6, 10 }
volume_chooser:onevent(lvgl.EVENT.VALUE_CHANGED, function()
-- luavgl dropdown binding uses 0-based indexing :(
local selection = volume_chooser:get('selected') + 1
volume.limit_db:set(limits[selection])
end)
theme.set_style(self.menu.content:Label {
text = "Left/Right balance",
}, "settings_title")
local balance = menu.content:Slider {
w = lvgl.PCT(100),
h = 5,
range = { min = -100, max = 100 },
value = 0,
}
balance:onevent(lvgl.EVENT.VALUE_CHANGED, function()
volume.left_bias:set(balance:value())
end)
local balance = self.menu.content:Slider {
w = lvgl.PCT(100),
h = 5,
range = { min = -100, max = 100 },
value = 0,
}
balance:onevent(lvgl.EVENT.VALUE_CHANGED, function()
volume.left_bias:set(balance:value())
end)
local balance_label = menu.content:Label {}
local balance_label = self.menu.content:Label {}
menu.bindings = {
volume.limit_db:bind(function(limit)
for i = 1, #limits do
if limits[i] == limit then
volume_chooser:set { selected = i - 1 }
self.bindings = {
volume.limit_db:bind(function(limit)
for i = 1, #limits do
if limits[i] == limit then
volume_chooser:set { selected = i - 1 }
end
end
end
end),
volume.left_bias:bind(function(bias)
balance:set {
value = bias
}
if bias < 0 then
balance_label:set {
text = string.format("Left %.2fdB", bias / 4)
end),
volume.left_bias:bind(function(bias)
balance:set {
value = bias
}
elseif bias > 0 then
balance_label:set {
text = string.format("Right %.2fdB", -bias / 4)
}
else
balance_label:set { text = "Balanced" }
end
end),
}
return menu
end
if bias < 0 then
balance_label:set {
text = string.format("Left %.2fdB", bias / 4)
}
elseif bias > 0 then
balance_label:set {
text = string.format("Right %.2fdB", -bias / 4)
}
else
balance_label:set { text = "Balanced" }
end
end),
}
end
}
function settings.display()
local menu = SettingsScreen("Display")
local DisplaySettings = screen:new {
createUi = function(self)
self.menu = SettingsScreen("Display")
local brightness_title = menu.content:Object {
local brightness_title = self.menu.content:Object {
flex = {
flex_direction = "row",
justify_content = "flex-start",
@ -198,91 +200,91 @@ function settings.display()
local brightness_pct = brightness_title:Label {}
theme.set_style(brightness_pct, "settings_title")
local brightness = menu.content:Slider {
w = lvgl.PCT(100),
h = 5,
range = { min = 0, max = 100 },
value = display.brightness:get(),
}
brightness:onevent(lvgl.EVENT.VALUE_CHANGED, function()
display.brightness:set(brightness:value())
end)
menu.bindings = {
display.brightness:bind(function(b)
brightness_pct:set { text = tostring(b) .. "%" }
local brightness = self.menu.content:Slider {
w = lvgl.PCT(100),
h = 5,
range = { min = 0, max = 100 },
value = display.brightness:get(),
}
brightness:onevent(lvgl.EVENT.VALUE_CHANGED, function()
display.brightness:set(brightness:value())
end)
}
return menu
end
self.bindings = {
display.brightness:bind(function(b)
brightness_pct:set { text = tostring(b) .. "%" }
end)
}
end
}
function settings.input()
local menu = SettingsScreen("Input Method")
local InputSettings = screen:new {
createUi = function(self)
self.menu = SettingsScreen("Input Method")
theme.set_style(menu.content:Label {
theme.set_style(self.menu.content:Label {
text = "Control scheme",
}, "settings_title")
local schemes = controls.schemes()
local option_to_scheme = {}
local scheme_to_option = {}
local schemes = controls.schemes()
local option_to_scheme = {}
local scheme_to_option = {}
local option_idx = 0
local options = ""
local option_idx = 0
local options = ""
for i, v in pairs(schemes) do
option_to_scheme[option_idx] = i
scheme_to_option[i] = option_idx
if option_idx > 0 then
options = options .. "\n"
for i, v in pairs(schemes) do
option_to_scheme[option_idx] = i
scheme_to_option[i] = option_idx
if option_idx > 0 then
options = options .. "\n"
end
options = options .. v
option_idx = option_idx + 1
end
options = options .. v
option_idx = option_idx + 1
end
local controls_chooser = menu.content:Dropdown {
options = options,
}
local controls_chooser = self.menu.content:Dropdown {
options = options,
}
menu.bindings = {
controls.scheme:bind(function(s)
local option = scheme_to_option[s]
controls_chooser:set({ selected = option })
end)
}
self.bindings = {
controls.scheme:bind(function(s)
local option = scheme_to_option[s]
controls_chooser:set({ selected = option })
end)
}
controls_chooser:onevent(lvgl.EVENT.VALUE_CHANGED, function()
local option = controls_chooser:get('selected')
local scheme = option_to_scheme[option]
controls.scheme:set(scheme)
end)
controls_chooser:onevent(lvgl.EVENT.VALUE_CHANGED, function()
local option = controls_chooser:get('selected')
local scheme = option_to_scheme[option]
controls.scheme:set(scheme)
end)
theme.set_style(menu.content:Label {
theme.set_style(self.menu.content:Label {
text = "Scroll Sensitivity",
}, "settings_title")
local slider_scale = 4; -- Power steering
local sensitivity = menu.content:Slider {
w = lvgl.PCT(90),
h = 5,
range = { min = 0, max = 255/slider_scale },
value = controls.scroll_sensitivity:get()/slider_scale,
}
sensitivity:onevent(lvgl.EVENT.VALUE_CHANGED, function()
controls.scroll_sensitivity:set(sensitivity:value()*slider_scale)
end)
return menu
end
local slider_scale = 4; -- Power steering
local sensitivity = self.menu.content:Slider {
w = lvgl.PCT(90),
h = 5,
range = { min = 0, max = 255 / slider_scale },
value = controls.scroll_sensitivity:get() / slider_scale,
}
sensitivity:onevent(lvgl.EVENT.VALUE_CHANGED, function()
controls.scroll_sensitivity:set(sensitivity:value() * slider_scale)
end)
end
}
function settings.database()
local menu = SettingsScreen("Database")
local db = require("database")
widgets.Row(menu.content, "Schema version", db.version())
widgets.Row(menu.content, "Size on disk", string.format("%.1f KiB", db.size() / 1024))
local DatabaseSettings = screen:new {
createUi = function(self)
self.menu = SettingsScreen("Database")
local db = require("database")
widgets.Row(self.menu.content, "Schema version", db.version())
widgets.Row(self.menu.content, "Size on disk", string.format("%.1f KiB", db.size() / 1024))
local actions_container = menu.content:Object {
local actions_container = self.menu.content:Object {
w = lvgl.PCT(100),
h = lvgl.SIZE_CONTENT,
flex = {
@ -296,64 +298,69 @@ function settings.database()
}
actions_container:add_style(styles.list_item)
local update = actions_container:Button {}
update:Label { text = "Update" }
update:onClicked(function()
database.update()
end)
end
local update = actions_container:Button {}
update:Label { text = "Update" }
update:onClicked(function()
database.update()
end)
end
}
local FirmwareSettings = screen:new {
createUi = function(self)
self.menu = SettingsScreen("Firmware")
local version = require("version")
widgets.Row(self.menu.content, "ESP32", version.esp())
widgets.Row(self.menu.content, "SAMD21", version.samd())
widgets.Row(self.menu.content, "Collator", version.collator())
end
}
function settings.firmware()
local menu = SettingsScreen("Firmware")
local version = require("version")
widgets.Row(menu.content, "ESP32", version.esp())
widgets.Row(menu.content, "SAMD21", version.samd())
widgets.Row(menu.content, "Collator", version.collator())
end
local LicensesScreen = screen:new {
createUi = function(self)
self.root = require("licenses")()
end
}
function settings.root()
local menu = widgets.MenuScreen {
show_back = true,
title = "Settings",
}
menu.list = menu.root:List {
w = lvgl.PCT(100),
h = lvgl.PCT(100),
flex_grow = 1,
}
return screen:new {
createUi = function(self)
self.menu = widgets.MenuScreen {
show_back = true,
title = "Settings",
}
self.list = self.menu.root:List {
w = lvgl.PCT(100),
h = lvgl.PCT(100),
flex_grow = 1,
}
local function section(name)
local elem = menu.list:Label {
local elem = self.list:Label {
text = name,
pad_left = 4,
}
theme.set_style(elem, "settings_title")
end
local function submenu(name, fn)
local item = menu.list:add_btn(nil, name)
item:onClicked(function()
backstack.push(fn)
end)
item:add_style(styles.list_item)
end
section("Audio")
submenu("Bluetooth", settings.bluetooth)
submenu("Headphones", settings.headphones)
section("Interface")
submenu("Display", settings.display)
submenu("Input Method", settings.input)
local function submenu(name, class)
local item = self.list:add_btn(nil, name)
item:onClicked(function()
backstack.push(class:new())
end)
item:add_style(styles.list_item)
end
section("System")
submenu("Database", settings.database)
submenu("Firmware", settings.firmware)
submenu("Licenses", function()
return require("licenses")()
end)
section("Audio")
submenu("Bluetooth", BluetoothSettings)
submenu("Headphones", HeadphonesSettings)
return menu
end
section("Interface")
submenu("Display", DisplaySettings)
submenu("Input Method", InputSettings)
return settings
section("System")
submenu("Database", DatabaseSettings)
submenu("Firmware", FirmwareSettings)
submenu("Licenses", LicensesScreen)
end
}

@ -1,11 +1,15 @@
--- @meta
--- The `alerts` module contains functions for showing transient popups over
--- the current screen.
--- @class alerts
local alerts = {}
--- @param constructor function
--- Shows a new alert, replacing any other alerts.
--- @param constructor function Called to create the UI for the alert. A new default root object and group will be set before calling this function.i Alerts are non-interactable; the group created for the constructor will not be granted focus.
function alerts.show(constructor) end
--- Dismisses any visible alerts, removing them from the screen.
function alerts.hide() end
return alerts

@ -1,11 +1,20 @@
--- @meta
--- The `backstack` module contains functions that can be used to implement a
--- basic stack-based navigation hierarchy. See also the `screen` module, which
--- provides a class prototype meant for use with this module.
--- @class backstack
local backstack = {}
--- @param constructor function
function backstack.push(constructor) end
--- Displays the given screen to the user. If there was already a screen being
--- displayed, then the current screen is removed from the display, and added
--- to the backstack.
--- @param screen screen The screen to display.
function backstack.push(screen) end
--- Removes the current screen from the display, then replaces it with the
--- screen that is at the top of the backstack. This function does nothing if
--- there are no other screens in the stack.
function backstack.pop() end
return backstack

@ -1,8 +1,12 @@
--- @meta
--- The 'bluetooth' module contains Properties and functions for interacting
--- with the device's Bluetooth capabilities.
--- @class bluetooth
--- @field enabled Property
--- @field connected Property
--- @field enabled Property Whether or not the Bluetooth stack is currently enabled. This property is writeable, and can be used to enable or disable Bluetooth.
--- @field connected Property Whether or not there is an active connection to another Bluetooth device.
--- @field paired_device Property The device that is currently paired. The bluetooth stack will automatically connected to this device if possible.
--- @field devices Property Devices nearby that have been discovered.
local bluetooth = {}
return bluetooth

@ -0,0 +1,12 @@
--- @meta
--- The `controls` module contains Properties relating to the device's physical
--- controls. These controls include the touchwheel, the lock switch, and the
--- side buttons.
--- @class controls
--- @field scheme Property The currently configured control scheme
--- @field scroll_sensitivity Property How much rotational motion is required on the touchwheel per scroll tick.
--- @field lock_switch Property The current state of the device's lock switch.
local controls = {}
return controls

@ -1,33 +1,60 @@
--- @meta
--- The `database` module contains Properties and functions for working with
--- the device's LevelDB-backed track database.
--- @class database
--- @field updating Property Whether or not a database re-index is currently in progress.
local database = {}
--- Returns a list of all indexes in the database.
--- @return Index[]
function database.indexes() end
--- An iterator is a userdata type that behaves like an ordinary Lua iterator.
--- @class Iterator
local Iterator = {}
--- A TrackId is a unique identifier, representing a playable track in the
--- user's library.
--- @class TrackId
local TrackId = {}
--- Gets the human-readable text representing this record. The `__tostring`
--- metatable function is an alias of this function.
--- @class Record
local Record = {}
--- @return string
function Record:title() end
--- @return TrackId|Iterator(Record)
--- Returns the value that this record represents. This may be either a track
--- id, for records which uniquely identify a track, or it may be a new
--- Iterator representing the next level of depth for the current index.
---
--- For example, each Record in the "All Albums" index corresponds to an entire
--- album of tracks; the 'contents' of such a Record is an iterator returning
--- each track in the album represented by the Record. The contents of each of
--- the returned 'track' Records would be a full Track, as there is no further
--- disambiguation needed.
--- @return TrackId|Iterator r A track id if this is a leaf record, otherwise a new iterator for the next level of this index.
function Record:contents() end
--- An index is heirarchical, sorted, view of the tracks within the database.
--- For example, the 'All Albums' index contains, first, a sorted list of every
--- album name in the library. Then, at the second level of the index, a sorted
--- list of every track within each album.
--- @class Index
local Index = {}
--- Gets the human-readable name of this index. This is typically something
--- like "All Albums", or "Albums by Artist". The `__tostring` metatable
--- function is an alias of this function.
--- @return string
function Index:name() end
--- @return Iterator(Record)
--- Returns a new iterator that can be used to access every record within the
--- first level of this index.
--- @return Iterator it An iterator that yields `Record`s.
function Index:iter() end
return database

@ -0,0 +1,9 @@
--- @meta
--- The `display` module contains Properties relating to the device's physical
--- display.
--- @class display
--- @field brightness Property The screen's current brightness, as a gamma-corrected percentage value from 0 to 100.
local display = {}
return display

File diff suppressed because it is too large Load Diff

@ -1,6 +1,7 @@
--- @meta
--- Properties for interacting with the audio playback system
--- The `playback` module contains Properties and functions for interacting
--- the device's audio pipeline.
--- @class playback
--- @field playing Property Whether or not audio is allowed to be played. if there is a current track, then this indicated whether playback is paused or unpaused. If there is no current track, this determines what will happen when the first track is added to the queue.
--- @field track Property The currently playing track.

@ -1,6 +1,7 @@
--- @meta
--- Properties and functions that deal with the device's battery and power state.
--- The `power` module contains properties and functions that relate to the
--- device's battery and charging state.
--- @class power
--- @field battery_pct Property The battery's current charge, as a percentage of the maximum charge.
--- @field battery_millivolts Property The battery's current voltage, in millivolts.

@ -1,15 +1,31 @@
--- @meta
--- Properties and functions for inspecting and manipulating the track playback queue
--- The `queue` module contains Properties and functions that relate to the
--- device's playback queue. This is a persistent, disk-backed list of TrackIds
--- that includes the currently playing track, tracks that have been played,
--- and tracks that are scheduled to be played after the current track has
--- finished.
--- @class queue
--- @field position Property The index in the queue of the currently playing track. This may be zero if the queue is empty. Writeable.
--- @field size Property The total number of tracks in the queue, including tracks which have already been played.
--- @field replay Property Whether or not the queue will be restarted after the final track is played. Writeable.
--- @field repeat_track Property Whether or not the current track will repeat indefinitely. Writeable.
--- @field random Property Determines whether, when progressing to the next track in the queue, the next track will be chosen randomly. The random selection algorithm used is a Miller Shuffle, which guarantees that no repeat selections will be made until every item in the queue has been played. Writeable.
--- @field random Property Determines whether, when progressing to the next track in the queue, the next track will be chosen randomly. The random selection algorithm used is a Miller Shuffle, which guarantees that no repeat selections will be made until every item in the queue has been played. Writeable.
local queue = {}
--- Adds the given track or database iterator to the end of the queue. Database
--- iterators passed to this method will be unnested and expanded into the track
--- ids they contain.
--- @param val TrackId|Iterator
function queue.add(val) end
--- Removes all tracks from the queue.
function queue.clear() end
--- Moves forward in the play queue, looping back around to the beginning if repeat is on.
function queue.next() end
--- Moves backward in the play queue, looping back around to the end if repeat is on.
function queue.previous() end
return queue

@ -0,0 +1,25 @@
--- @meta
--- A distinct full-screen UI. Each screen has an associated LVGL UI tree and
--- group, and can be shown on-screen using the 'backstack' module.
--- Screens make use of prototype inheritance in order to provide a consistent
--- interface for the C++ firmware to work with.
--- See [Programming in Lua, chapter 16](https://www.lua.org/pil/16.2.html).
--- @class screen
local screen = {}
--- Creates a new screen instance.
function screen:new(params) end
--- Called just before this screen is first displayed to the user.
function screen:createUi() end
--- Called whenever this screen is displayed to the user.
function screen:onShown() end
--- Called whenever this screen is being hidden by the user; either because a
--- new screen is being pushed on top of this way, or because this screen has
--- been popped off of the stack.
function screen:onHidden() end
return screen

@ -0,0 +1,12 @@
--- @meta
--- The `time` module contains functions for dealing with the current time
--- since boot.
--- @class time
local time = {}
--- Returns the time in milliseconds since the device booted.
--- @return integer
function time.ticks() end
return time

@ -1,13 +1,23 @@
--- @meta
--- A observable value, owned by the C++ firmware.
---@class Property
local property = {}
--- @return integer|string|table|boolean val Returns the property's current value
function property:get() end
--- Sets a new value. Not all properties may be set from within Lua code. For
--- example, it makes little sense to attempt to override the current battery
--- level.
--- @param val? integer|string|table|boolean The new value. Optional; if not argument is passed, the property will be set to 'nil'.
--- @return boolean success whether or not the new value was accepted
function property:set(val) end
--- @param fn function
--- Invokes the given function once immediately with the current value, and then again whenever the value changes.
--- The function is invoked for *all* changes; both from the underlying C++ data, and from calls to `set` (if this is a Lua-writeable property).
--- The binding will be active **only** so long as the given function remains in scope.
--- @param fn function callback to apply property values. Must accept one argument; the updated value.
function property:bind(fn) end
return property

@ -1,8 +1,11 @@
--- @meta
--- Module for interacting with playback volume. The Bluetooth and wired outputs store their current volume separately; this API only allows interacting with the volume of the currently used output device.
--- @class volume
--- @field current_pct Property
--- @field current_db Property
--- @field current_pct Property The current volume as a percentage of the current volume limit.
--- @field current_db Property The current volume in terms of decibels relative to line level (only applicable to headphone output)
--- @field left_bias Property An additional modifier in decibels to apply to the left channel (only applicable to headphone output)
--- @field limit_db Property The maximum allowed output volume, in terms of decibels relative to line level (only applicable to headphone output)
local volume = {}
return volume

@ -13,6 +13,8 @@
#include "audio_sink.hpp"
#include "bluetooth_types.hpp"
#include "cppbor.h"
#include "cppbor_parse.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
@ -58,6 +60,8 @@ StreamBufferHandle_t AudioState::sDrainBuffer;
std::optional<database::TrackId> AudioState::sCurrentTrack;
bool AudioState::sIsPlaybackAllowed;
static std::optional<std::pair<std::string, uint32_t>> sLastTrackUpdate;
void AudioState::react(const system_fsm::BluetoothEvent& ev) {
if (ev.event != drivers::bluetooth::Event::kConnectionStateChanged) {
return;
@ -310,11 +314,15 @@ void Standby::react(const QueueUpdate& ev) {
if (!current_track || (sCurrentTrack && (*sCurrentTrack == *current_track))) {
return;
}
if (ev.reason == QueueUpdate::Reason::kDeserialised && sLastTrackUpdate) {
return;
}
clearDrainBuffer();
playTrack(*current_track);
}
static const char kQueueKey[] = "audio:queue";
static const char kCurrentFileKey[] = "audio:current";
void Standby::react(const system_fsm::KeyLockChanged& ev) {
if (!ev.locking) {
@ -332,6 +340,14 @@ void Standby::react(const system_fsm::KeyLockChanged& ev) {
return;
}
db->put(kQueueKey, queue.serialise());
if (sLastTrackUpdate) {
cppbor::Array current_track{
cppbor::Tstr{sLastTrackUpdate->first},
cppbor::Uint{sLastTrackUpdate->second},
};
db->put(kCurrentFileKey, current_track.toString());
}
});
}
@ -341,13 +357,32 @@ void Standby::react(const system_fsm::StorageMounted& ev) {
if (!db) {
return;
}
auto res = db->get(kQueueKey);
if (res) {
// Restore the currently playing file before restoring the queue. This way,
// we can fall back to restarting the queue's current track if there's any
// issue restoring the current file.
auto current = db->get(kCurrentFileKey);
if (current) {
// Again, ensure we don't boot-loop by trying to play a track that causes
// a crash over and over again.
db->put(kCurrentFileKey, "");
auto [parsed, unused, err] = cppbor::parse(
reinterpret_cast<uint8_t*>(current->data()), current->size());
if (parsed->type() == cppbor::ARRAY) {
std::string filename = parsed->asArray()->get(0)->asTstr()->value();
uint32_t pos = parsed->asArray()->get(1)->asUint()->value();
sLastTrackUpdate = std::make_pair(filename, pos);
sFileSource->SetPath(filename, pos);
}
}
auto queue = db->get(kQueueKey);
if (queue) {
// Don't restore the same queue again. This ideally should do nothing,
// but guards against bad edge cases where restoring the queue ends up
// causing a crash.
db->put(kQueueKey, "");
sServices->track_queue().deserialise(*res);
sServices->track_queue().deserialise(*queue);
}
});
}
@ -399,6 +434,7 @@ void Playback::react(const QueueUpdate& ev) {
void Playback::react(const PlaybackUpdate& ev) {
ESP_LOGI(kTag, "elapsed: %lu, total: %lu", ev.seconds_elapsed,
ev.track->duration);
sLastTrackUpdate = std::make_pair(ev.track->filepath, ev.seconds_elapsed);
}
void Playback::react(const internal::InputFileOpened& ev) {}
@ -407,6 +443,7 @@ void Playback::react(const internal::InputFileClosed& ev) {}
void Playback::react(const internal::InputFileFinished& ev) {
ESP_LOGI(kTag, "finished playing file");
sLastTrackUpdate.reset();
sServices->track_queue().finish();
if (!sServices->track_queue().current()) {
for (int i = 0; i < 20; i++) {

@ -44,6 +44,7 @@ struct QueueUpdate : tinyfsm::Event {
kExplicitUpdate,
kRepeatingLastTrack,
kTrackFinished,
kDeserialised,
};
Reason reason;
};

@ -486,7 +486,7 @@ auto TrackQueue::deserialise(const std::string& s) -> void {
QueueParseClient client{*this};
const uint8_t* data = reinterpret_cast<const uint8_t*>(s.data());
cppbor::parse(data, data + s.size(), &client);
notifyChanged(true, Reason::kExplicitUpdate);
notifyChanged(true, Reason::kDeserialised);
}
} // namespace audio

@ -73,7 +73,10 @@ auto Battery::Update() -> void {
} else {
is_charging = *charge_state == ChargeStatus::kChargingRegular ||
*charge_state == ChargeStatus::kChargingFast ||
*charge_state == ChargeStatus::kFullCharge;
*charge_state == ChargeStatus::kFullCharge ||
// Treat 'no battery' as charging because, for UI purposes,
// we're *kind of* at full charge if u think about it.
*charge_state == ChargeStatus::kNoBattery;
}
if (state_ && state_->is_charging == is_charging &&

@ -229,13 +229,17 @@ auto Database::sizeOnDiskBytes() -> size_t {
}
auto Database::put(const std::string& key, const std::string& val) -> void {
db_->Put(leveldb::WriteOptions{}, kKeyCustom + key, val);
if (val.empty()) {
db_->Delete(leveldb::WriteOptions{}, kKeyCustom + key);
} else {
db_->Put(leveldb::WriteOptions{}, kKeyCustom + key, val);
}
}
auto Database::get(const std::string& key) -> std::optional<std::string> {
std::string val;
auto res = db_->Get(leveldb::ReadOptions{}, kKeyCustom + key, &val);
if (!res.ok()) {
if (!res.ok() || val.empty()) {
return {};
}
return val;

@ -39,9 +39,6 @@
[[maybe_unused]] static const char* kTag = "DISPLAY";
// TODO(jacqueline): Encode width and height variations in the init data.
static const uint8_t kDisplayHeight = 128 + 2;
static const uint8_t kDisplayWidth = 160 + 1;
static const uint8_t kTransactionQueueSize = 2;
static const gpio_num_t kDisplayDr = GPIO_NUM_33;
@ -51,9 +48,11 @@ static const gpio_num_t kDisplayCs = GPIO_NUM_22;
/*
* The size of each of our two display buffers. This is fundamentally a balance
* between performance and memory usage. LVGL docs recommend a buffer 1/10th the
* size of the screen is the best tradeoff
* size of the screen is the best tradeoff.
8
* The 160x128 is the nominal size of our standard faceplate's display.
*/
static const int kDisplayBufferSize = kDisplayWidth * kDisplayHeight / 10;
static const int kDisplayBufferSize = 160 * 128 / 10;
DMA_ATTR static lv_color_t kDisplayBuffer[kDisplayBufferSize];
namespace drivers {
@ -154,10 +153,8 @@ auto Display::Create(IGpios& expander,
lv_disp_drv_init(&display->driver_);
display->driver_.draw_buf = &display->buffers_;
display->driver_.hor_res = kDisplayWidth;
display->driver_.ver_res = kDisplayHeight;
// display->driver_.sw_rotate = 1;
// display->driver_.rotated = LV_DISP_ROT_270;
display->driver_.hor_res = init_data.width;
display->driver_.ver_res = init_data.height;
display->driver_.sw_rotate = 0;
display->driver_.rotated = LV_DISP_ROT_NONE;
display->driver_.antialiasing = 0;

@ -101,6 +101,8 @@ static const uint8_t kST7735RCommonFooter[]{
// clang-format on
const InitialisationData kST7735R = {
.width = 160,
.height = 128,
.num_sequences = 3,
.sequences = {kST7735RCommonHeader, kST7735RCommonGreen,
kST7735RCommonFooter}};

@ -63,8 +63,8 @@ constexpr std::pair<uint8_t, uint8_t> unpack(uint16_t ba) {
static constexpr gpio_num_t kIntPin = GPIO_NUM_34;
auto Gpios::Create() -> Gpios* {
Gpios* instance = new Gpios();
auto Gpios::Create(bool invert_lock) -> Gpios* {
Gpios* instance = new Gpios(invert_lock);
// Read and write initial values on initialisation so that we do not have a
// strange partially-initialised state.
if (!instance->Flush() || !instance->Read()) {
@ -73,7 +73,10 @@ auto Gpios::Create() -> Gpios* {
return instance;
}
Gpios::Gpios() : ports_(pack(kPortADefault, kPortBDefault)), inputs_(0) {
Gpios::Gpios(bool invert_lock)
: ports_(pack(kPortADefault, kPortBDefault)),
inputs_(0),
invert_lock_switch_(invert_lock) {
gpio_set_direction(kIntPin, GPIO_MODE_INPUT);
}
@ -108,6 +111,15 @@ auto Gpios::Get(Pin pin) const -> bool {
return (inputs_ & (1 << static_cast<int>(pin))) > 0;
}
auto Gpios::IsLocked() const -> bool {
bool pin = Get(Pin::kKeyLock);
if (invert_lock_switch_) {
return pin;
} else {
return !pin;
}
}
auto Gpios::Read() -> bool {
uint8_t input_a, input_b;

@ -6,6 +6,7 @@
#pragma once
#include <stdint.h>
#include <cstdint>
namespace drivers {
@ -14,6 +15,8 @@ namespace displays {
extern const uint8_t kDelayBit;
struct InitialisationData {
uint16_t width;
uint16_t height;
uint8_t num_sequences;
const uint8_t* sequences[4];
};

@ -79,12 +79,12 @@ class IGpios {
*/
virtual auto Get(Pin) const -> bool = 0;
virtual auto IsLocked() const -> bool { return Get(Pin::kKeyLock); }
virtual auto IsLocked() const -> bool = 0;
};
class Gpios : public IGpios {
public:
static auto Create() -> Gpios*;
static auto Create(bool invert_lock_switch) -> Gpios*;
~Gpios();
/*
@ -106,6 +106,8 @@ class Gpios : public IGpios {
auto Get(Pin) const -> bool override;
auto IsLocked() const -> bool override;
/**
* Reads from the GPIO expander, populating `inputs` with the most recent
* values.
@ -118,10 +120,11 @@ class Gpios : public IGpios {
Gpios& operator=(const Gpios&) = delete;
private:
Gpios();
Gpios(bool invert_lock);
std::atomic<uint16_t> ports_;
std::atomic<uint16_t> inputs_;
const bool invert_lock_switch_;
};
} // namespace drivers

@ -71,6 +71,11 @@ class NvsStorage {
auto LockPolarity() -> bool;
auto LockPolarity(bool) -> void;
auto DisplaySize()
-> std::pair<std::optional<uint16_t>, std::optional<uint16_t>>;
auto DisplaySize(std::pair<std::optional<uint16_t>, std::optional<uint16_t>>)
-> void;
auto PreferredBluetoothDevice() -> std::optional<bluetooth::MacAndName>;
auto PreferredBluetoothDevice(std::optional<bluetooth::MacAndName>) -> void;
@ -120,6 +125,9 @@ class NvsStorage {
nvs_handle_t handle_;
Setting<uint8_t> lock_polarity_;
Setting<uint16_t> display_cols_;
Setting<uint16_t> display_rows_;
Setting<uint8_t> brightness_;
Setting<uint8_t> sensitivity_;
Setting<uint16_t> amp_max_vol_;

@ -37,6 +37,8 @@ static constexpr char kKeyAmpLeftBias[] = "hp_bias";
static constexpr char kKeyPrimaryInput[] = "in_pri";
static constexpr char kKeyScrollSensitivity[] = "scroll";
static constexpr char kKeyLockPolarity[] = "lockpol";
static constexpr char kKeyDisplayCols[] = "dispcols";
static constexpr char kKeyDisplayRows[] = "disprows";
static auto nvs_get_string(nvs_handle_t nvs, const char* key)
-> std::optional<std::string> {
@ -161,6 +163,8 @@ auto NvsStorage::OpenSync() -> NvsStorage* {
NvsStorage::NvsStorage(nvs_handle_t handle)
: handle_(handle),
lock_polarity_(kKeyLockPolarity),
display_cols_(kKeyDisplayCols),
display_rows_(kKeyDisplayRows),
brightness_(kKeyBrightness),
sensitivity_(kKeyScrollSensitivity),
amp_max_vol_(kKeyAmpMaxVolume),
@ -180,6 +184,8 @@ NvsStorage::~NvsStorage() {
auto NvsStorage::Read() -> void {
std::lock_guard<std::mutex> lock{mutex_};
lock_polarity_.read(handle_);
display_cols_.read(handle_);
display_rows_.read(handle_);
brightness_.read(handle_);
sensitivity_.read(handle_);
amp_max_vol_.read(handle_);
@ -194,6 +200,8 @@ auto NvsStorage::Read() -> void {
auto NvsStorage::Write() -> bool {
std::lock_guard<std::mutex> lock{mutex_};
lock_polarity_.write(handle_);
display_cols_.write(handle_);
display_rows_.write(handle_);
brightness_.write(handle_);
sensitivity_.write(handle_);
amp_max_vol_.write(handle_);
@ -231,6 +239,19 @@ auto NvsStorage::LockPolarity(bool p) -> void {
lock_polarity_.set(p);
}
auto NvsStorage::DisplaySize()
-> std::pair<std::optional<uint16_t>, std::optional<uint16_t>> {
std::lock_guard<std::mutex> lock{mutex_};
return std::make_pair(display_cols_.get(), display_rows_.get());
}
auto NvsStorage::DisplaySize(
std::pair<std::optional<uint16_t>, std::optional<uint16_t>> size) -> void {
std::lock_guard<std::mutex> lock{mutex_};
display_cols_.set(std::move(size.first));
display_rows_.set(std::move(size.second));
}
auto NvsStorage::PreferredBluetoothDevice()
-> std::optional<bluetooth::MacAndName> {
std::lock_guard<std::mutex> lock{mutex_};

@ -77,29 +77,16 @@ auto Samd::UpdateChargeStatus() -> void {
return;
}
// FIXME: Ideally we should be using the three 'charge status' bits to work
// out whether we're actually charging, or if we've got a full charge,
// critically low charge, etc.
uint8_t usb_state = raw_res & 0b11;
uint8_t charge_state = (raw_res >> 2) & 0b111;
switch (charge_state) {
case 0b000:
case 0b011:
charge_status_ = ChargeStatus::kNoBattery;
break;
case 0b001:
charge_status_ = usb_state == 1 ? ChargeStatus::kChargingRegular
: ChargeStatus::kChargingFast;
break;
case 0b010:
charge_status_ = ChargeStatus::kFullCharge;
break;
case 0b100:
charge_status_ = ChargeStatus::kBatteryCritical;
break;
case 0b101:
charge_status_ = ChargeStatus::kDischarging;
break;
default:
charge_status_ = {};
break;
if (usb_state == 0) {
charge_status_ = ChargeStatus::kDischarging;
} else if (usb_state == 1) {
charge_status_ = ChargeStatus::kChargingRegular;
} else {
charge_status_ = ChargeStatus::kChargingFast;
}
}

@ -5,6 +5,7 @@
idf_component_register(
SRCS "lua_theme.cpp" "lua_thread.cpp" "bridge.cpp" "property.cpp" "lua_database.cpp"
"lua_queue.cpp" "lua_version.cpp" "lua_theme.cpp" "lua_controls.cpp" "registry.cpp"
"lua_screen.cpp"
INCLUDE_DIRS "include"
REQUIRES "drivers" "lvgl" "tinyfsm" "events" "system_fsm" "database"
"esp_timer" "battery" "esp-idf-lua" "luavgl" "lua-linenoise" "lua-term"

@ -19,6 +19,7 @@
#include "lua_controls.hpp"
#include "lua_database.hpp"
#include "lua_queue.hpp"
#include "lua_screen.hpp"
#include "lua_version.hpp"
#include "lua_theme.hpp"
#include "lvgl.h"
@ -86,6 +87,7 @@ auto Bridge::installBaseModules(lua_State* L) -> void {
RegisterQueueModule(L);
RegisterVersionModule(L);
RegisterThemeModule(L);
RegisterScreenModule(L);
}
auto Bridge::installLvgl(lua_State* L) -> void {

@ -0,0 +1,15 @@
/*
* Copyright 2023 jacqueline <me@jacqueline.id.au>
*
* SPDX-License-Identifier: GPL-3.0-only
*/
#pragma once
#include "lua.hpp"
namespace lua {
auto RegisterScreenModule(lua_State*) -> void;
} // namespace lua

@ -0,0 +1,75 @@
/*
* Copyright 2023 jacqueline <me@jacqueline.id.au>
*
* SPDX-License-Identifier: GPL-3.0-only
*/
#include "lua_screen.hpp"
#include <memory>
#include <string>
#include "lua.hpp"
#include "esp_log.h"
#include "lauxlib.h"
#include "lua.h"
#include "lvgl.h"
#include "bridge.hpp"
#include "database.hpp"
#include "event_queue.hpp"
#include "index.hpp"
#include "property.hpp"
#include "service_locator.hpp"
#include "track.hpp"
#include "track_queue.hpp"
#include "ui_events.hpp"
namespace lua {
static auto screen_new(lua_State* L) -> int {
// o = o or {}
if (lua_gettop(L) != 2) {
lua_settop(L, 1);
lua_newtable(L);
}
// Swap o and self on the stack.
lua_insert(L, 1);
lua_pushliteral(L, "__index");
lua_pushvalue(L, 1);
lua_settable(L, 1); // self.__index = self
lua_setmetatable(L, 1); // setmetatable(o, self)
return 1; // return o
}
static auto screen_noop(lua_State* state) -> int {
return 0;
}
static const struct luaL_Reg kScreenFuncs[] = {{"new", screen_new},
{"createUi", screen_noop},
{"onShown", screen_noop},
{"onHidden", screen_noop},
{NULL, NULL}};
static auto lua_screen(lua_State* state) -> int {
luaL_newlib(state, kScreenFuncs);
lua_pushliteral(state, "__index");
lua_pushvalue(state, -2);
lua_rawset(state, -3);
return 1;
}
auto RegisterScreenModule(lua_State* s) -> void {
luaL_requiref(s, "screen", lua_screen, true);
lua_pop(s, 1);
}
} // namespace lua

@ -57,13 +57,22 @@ auto Booting::entry() -> void {
sServices.reset(new ServiceLocator());
ESP_LOGI(kTag, "installing early drivers");
// NVS is needed first because it contains information about what specific
// hardware configuration we're running on.
sServices->nvs(
std::unique_ptr<drivers::NvsStorage>(drivers::NvsStorage::OpenSync()));
// HACK: fix up the switch polarity on newer dev units
sServices->nvs().LockPolarity(false);
// I2C and SPI are both always needed. We can't even power down or show an
// error without these.
ESP_ERROR_CHECK(drivers::init_spi());
sServices->gpios(std::unique_ptr<drivers::Gpios>(drivers::Gpios::Create()));
sServices->gpios(std::unique_ptr<drivers::Gpios>(
drivers::Gpios::Create(sServices->nvs().LockPolarity())));
ESP_LOGI(kTag, "starting ui");
if (!ui::UiState::InitBootSplash(sServices->gpios())) {
if (!ui::UiState::InitBootSplash(sServices->gpios(), sServices->nvs())) {
events::System().Dispatch(FatalError{});
return;
}
@ -74,8 +83,6 @@ auto Booting::entry() -> void {
ESP_LOGI(kTag, "installing remaining drivers");
drivers::spiffs_mount();
sServices->samd(std::unique_ptr<drivers::Samd>(drivers::Samd::Create()));
sServices->nvs(
std::unique_ptr<drivers::NvsStorage>(drivers::NvsStorage::OpenSync()));
sServices->touchwheel(
std::unique_ptr<drivers::TouchWheel>{drivers::TouchWheel::Create()});
sServices->haptics(std::make_unique<drivers::Haptics>());
@ -100,8 +107,6 @@ auto Booting::entry() -> void {
sServices->bluetooth().Enable();
}
sServices->nvs().LockPolarity(true);
BootComplete ev{.services = sServices};
events::Audio().Dispatch(ev);
events::Ui().Dispatch(ev);

@ -57,7 +57,6 @@ struct SamdUsbMscChanged : tinyfsm::Event {
bool en;
};
struct ChargingStatusChanged : tinyfsm::Event {};
struct BatteryStateChanged : tinyfsm::Event {
battery::Battery::BatteryState new_state;
};

@ -84,10 +84,8 @@ void SystemState::react(const internal::SamdInterrupt&) {
auto charge_status = samd.GetChargeStatus();
auto usb_status = samd.GetUsbStatus();
if (charge_status != prev_charge_status) {
ChargingStatusChanged ev{};
events::System().Dispatch(ev);
events::Ui().Dispatch(ev);
if (charge_status != prev_charge_status && sServices) {
sServices->battery().Update();
}
if (usb_status != prev_usb_status) {
ESP_LOGI(kTag, "usb status changed");

@ -27,6 +27,9 @@ class Screen {
Screen();
virtual ~Screen();
virtual auto onShown() -> void {}
virtual auto onHidden() -> void {}
auto root() -> lv_obj_t* { return root_; }
auto content() -> lv_obj_t* { return content_; }
auto alert() -> lv_obj_t* { return alert_; }

@ -18,6 +18,9 @@ class Lua : public Screen {
Lua();
~Lua();
auto onShown() -> void override;
auto onHidden() -> void override;
auto SetObjRef(lua_State*) -> void;
private:

@ -36,7 +36,7 @@ namespace ui {
class UiState : public tinyfsm::Fsm<UiState> {
public:
static auto InitBootSplash(drivers::IGpios&) -> bool;
static auto InitBootSplash(drivers::IGpios&, drivers::NvsStorage&) -> bool;
virtual ~UiState() {}
@ -129,6 +129,7 @@ class UiState : public tinyfsm::Fsm<UiState> {
static lua::Property sControlsScheme;
static lua::Property sScrollSensitivity;
static lua::Property sLockSwitch;
static lua::Property sDatabaseUpdating;
};

@ -35,6 +35,9 @@ Screen::Screen()
lv_obj_set_style_bg_opa(modal_content_, LV_OPA_TRANSP, 0);
lv_obj_set_style_bg_opa(alert_, LV_OPA_TRANSP, 0);
lv_obj_set_scrollbar_mode(root_, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_scrollbar_mode(content_, LV_SCROLLBAR_MODE_OFF);
// Disable wrapping by default, since it's confusing and generally makes it
// harder to navigate quickly.
lv_group_set_wrap(group_, false);

@ -7,9 +7,11 @@
#include "screen_lua.hpp"
#include "core/lv_obj_tree.h"
#include "lua.h"
#include "lua.hpp"
#include "themes.hpp"
#include "lua_thread.hpp"
#include "luavgl.h"
namespace ui {
@ -25,6 +27,40 @@ Lua::~Lua() {
}
}
auto Lua::onShown() -> void {
if (!s_ || !obj_ref_) {
return;
}
lua_rawgeti(s_, LUA_REGISTRYINDEX, *obj_ref_);
lua_pushliteral(s_, "onShown");
if (lua_gettable(s_, -2) == LUA_TFUNCTION) {
lua_pushvalue(s_, -2);
lua::CallProtected(s_, 1, 0);
} else {
lua_pop(s_, 1);
}
lua_pop(s_, 1);
}
auto Lua::onHidden() -> void {
if (!s_ || !obj_ref_) {
return;
}
lua_rawgeti(s_, LUA_REGISTRYINDEX, *obj_ref_);
lua_pushliteral(s_, "onHidden");
if (lua_gettable(s_, -2) == LUA_TFUNCTION) {
lua_pushvalue(s_, -2);
lua::CallProtected(s_, 1, 0);
} else {
lua_pop(s_, 1);
}
lua_pop(s_, 1);
}
auto Lua::SetObjRef(lua_State* s) -> void {
assert(s_ == nullptr);
s_ = s;

@ -12,6 +12,7 @@
#include "bluetooth_types.hpp"
#include "db_events.hpp"
#include "display_init.hpp"
#include "freertos/portmacro.h"
#include "freertos/projdefs.h"
#include "lua.h"
@ -125,21 +126,24 @@ lua::Property UiState::sPlaybackPlaying{
}};
lua::Property UiState::sPlaybackTrack{};
lua::Property UiState::sPlaybackPosition{0, [](const lua::LuaValue& val) {
int current_val = std::get<int>(sPlaybackPosition.Get());
if (!std::holds_alternative<int>(val)) {
return false;
}
int new_val = std::get<int>(val);
if (current_val != new_val) {
auto track = sPlaybackTrack.Get();
if (!std::holds_alternative<audio::Track>(track)) {
lua::Property UiState::sPlaybackPosition{
0, [](const lua::LuaValue& val) {
int current_val = std::get<int>(sPlaybackPosition.Get());
if (!std::holds_alternative<int>(val)) {
return false;
}
events::Audio().Dispatch(audio::SeekFile{.offset = (uint32_t)new_val, .filename = std::get<audio::Track>(track).filepath});
}
return true;
}};
int new_val = std::get<int>(val);
if (current_val != new_val) {
auto track = sPlaybackTrack.Get();
if (!std::holds_alternative<audio::Track>(track)) {
return false;
}
events::Audio().Dispatch(audio::SeekFile{
.offset = (uint32_t)new_val,
.filename = std::get<audio::Track>(track).filepath});
}
return true;
}};
lua::Property UiState::sQueuePosition{0};
lua::Property UiState::sQueueSize{0};
@ -277,12 +281,25 @@ lua::Property UiState::sScrollSensitivity{
return true;
}};
lua::Property UiState::sLockSwitch{false};
lua::Property UiState::sDatabaseUpdating{false};
auto UiState::InitBootSplash(drivers::IGpios& gpios) -> bool {
auto UiState::InitBootSplash(drivers::IGpios& gpios, drivers::NvsStorage& nvs)
-> bool {
// Init LVGL first, since the display driver registers itself with LVGL.
lv_init();
sDisplay.reset(drivers::Display::Create(gpios, drivers::displays::kST7735R));
drivers::displays::InitialisationData init_data = drivers::displays::kST7735R;
// HACK: correct the display size for our prototypes.
// nvs.DisplaySize({161, 130});
auto actual_size = nvs.DisplaySize();
init_data.width = actual_size.first.value_or(init_data.width);
init_data.height = actual_size.second.value_or(init_data.height);
sDisplay.reset(drivers::Display::Create(gpios, init_data));
if (sDisplay == nullptr) {
return false;
}
@ -294,27 +311,36 @@ auto UiState::InitBootSplash(drivers::IGpios& gpios) -> bool {
}
void UiState::PushScreen(std::shared_ptr<Screen> screen) {
lv_obj_set_parent(sAlertContainer, screen->alert());
if (sCurrentScreen) {
sCurrentScreen->onHidden();
sScreens.push(sCurrentScreen);
}
sCurrentScreen = screen;
lv_obj_set_parent(sAlertContainer, sCurrentScreen->alert());
sCurrentScreen->onShown();
}
int UiState::PopScreen() {
if (sScreens.empty()) {
return 0;
}
sCurrentScreen = sScreens.top();
lv_obj_set_parent(sAlertContainer, sCurrentScreen->alert());
lv_obj_set_parent(sAlertContainer, sScreens.top()->alert());
sCurrentScreen->onHidden();
sCurrentScreen = sScreens.top();
sScreens.pop();
sCurrentScreen->onShown();
return sScreens.size();
}
void UiState::react(const system_fsm::KeyLockChanged& ev) {
sDisplay->SetDisplayOn(!ev.locking);
sInput->lock(ev.locking);
sLockSwitch.Update(ev.locking);
}
void UiState::react(const internal::ControlSchemeChanged&) {
@ -506,6 +532,7 @@ void Lua::entry() {
{
{"scheme", &sControlsScheme},
{"scroll_sensitivity", &sScrollSensitivity},
{"lock_switch", &sLockSwitch},
});
registry.AddPropertyModule(
@ -540,7 +567,7 @@ void Lua::entry() {
auto Lua::PushLuaScreen(lua_State* s) -> int {
// Ensure the arg looks right before continuing.
luaL_checktype(s, 1, LUA_TFUNCTION);
luaL_checktype(s, 1, LUA_TTABLE);
// First, create a new plain old Screen object. We will use its root and
// group for the Lua screen. Allocate it in external ram so that arbitrarily
@ -555,10 +582,15 @@ auto Lua::PushLuaScreen(lua_State* s) -> int {
lv_group_set_default(new_screen->group());
// Call the constructor for this screen.
lua_settop(s, 1); // Make sure the function is actually at top of stack
lua::CallProtected(s, 0, 1);
// lua_settop(s, 1); // Make sure the screen is actually at top of stack
lua_pushliteral(s, "createUi");
if (lua_gettable(s, 1) == LUA_TFUNCTION) {
lua_pushvalue(s, 1);
lua::CallProtected(s, 1, 0);
}
// Store the reference for the table the constructor returned.
// Store the reference for this screen's table.
lua_settop(s, 1);
new_screen->SetObjRef(s);
// Finally, push the now-initialised screen as if it were a regular C++
@ -586,7 +618,7 @@ auto Lua::PopLuaScreen(lua_State* s) -> int {
}
auto Lua::Ticks(lua_State* s) -> int {
lua_pushinteger(s, esp_timer_get_time()/1000);
lua_pushinteger(s, esp_timer_get_time() / 1000);
return 1;
}

@ -5,7 +5,7 @@
# For more information about build system see
# https://docs.espressif.com/projects/esp-idf/en/latest/api-guides/build-system.html
set(PROJECT_VER "0.7.0")
set(PROJECT_VER "0.7.1")
# esp-idf sets the C++ standard weird. Set cmake vars to match.
set(CMAKE_CXX_STANDARD 23)

@ -0,0 +1,202 @@
#!/usr/bin/env lua
local json = require "json"
if #arg > 0 then
print("usage:", arg[0])
print([[
reads a lua-language-server json doc output from stdin, converts it into
markdown, and writes the result to stdout]])
return
end
local raw_data = io.read("*all")
local parsed = json.decode(raw_data)
local definitions_per_module = {}
local fields_per_class = {}
for _, class in ipairs(parsed) do
if not class.defines or not class.defines[1] then goto continue end
-- Filter out any definitions that didn't come from us.
local path = class.defines[1].file
if not string.find(path, "/luals-stubs/", 1, true) then goto continue end
local module_name = string.gsub(path, ".*/(%a*)%.lua", "%1")
local module = definitions_per_module[module_name] or {}
module[class.name] = class
definitions_per_module[module_name] = module
local fields = {}
for _, field in ipairs(class.fields or {}) do
fields[field.name] = true
end
fields_per_class[class.name] = fields
::continue::
end
local function sortedPairs(t)
local keys = {}
for key in pairs(t) do
table.insert(keys, key)
end
table.sort(keys)
local generator = coroutine.create(function()
for _, key in ipairs(keys) do
coroutine.yield(key, t[key])
end
end)
return function()
local _, key, val = coroutine.resume(generator)
return key, val
end
end
local function printHeading(level, text)
local hashes = ""
for _ = 1, level do hashes = hashes .. "#" end
print(hashes .. " " .. text)
end
local function filterArgs(field)
if not field.extends.args then return {} end
local ret = {}
for _, arg in ipairs(field.extends.args) do
if arg.name ~= "self" then table.insert(ret, arg) end
end
return ret
end
local function filterReturns(field)
if not field.extends.returns then return {} end
local ret = {}
for _, r in ipairs(field.extends.returns) do
if r.desc then table.insert(ret, r) end
end
return ret
end
local function emitField(level, prefix, field)
if not field.desc then return end
printHeading(level, "`" .. prefix .. "." .. field.name .. "`")
print()
print("`" .. field.extends.view .. "`")
print()
if field.rawdesc then
print(field.rawdesc)
print()
end
local args = filterArgs(field)
if #args > 0 then
printHeading(level + 1, "Arguments")
print()
for _, arg in ipairs(args) do
print(string.format(" - *%s*: %s", arg.name, arg.desc))
end
print()
end
local rets = filterReturns(field)
if #rets > 0 then
printHeading(level + 1, "Returns")
print()
for _, ret in ipairs(rets) do
if #rets > 1 then
print(" - " .. ret.desc)
else
print(ret.desc)
end
end
print()
end
end
local function baseClassName(class)
for _, define in ipairs(class.defines or {}) do
for _, extend in ipairs(define.extends or {}) do
if extend.type == "doc.extends.name" then
return extend.view
end
end
end
end
local function isEnum(class)
for _, define in pairs(class.defines) do
if define.type == "doc.enum" then return true end
end
return false
end
local function isAlias(class)
for _, define in pairs(class.defines) do
if define.type == "doc.alias" then return true end
end
return false
end
local function emitClass(level, prefix, class)
if not class.name then return end
if not class.fields then return end
if isAlias(class) then return end
for _, define in ipairs(class.defines or {}) do
if define.type == "tablefield" then
print(" - " .. class.name)
return
end
end
printHeading(level, "`" .. prefix .. "." .. class.name .. "`")
print()
local base_class = baseClassName(class)
local base_class_fields = {}
if base_class then
base_class_fields = fields_per_class[base_class] or {}
print("`" .. class.name .. ":" .. base_class .. "`")
print()
end
if class.desc then print(class.desc) end
for _, field in ipairs(class.fields or {}) do
if not base_class_fields[field.name] then
emitField(level + 1, class.name, field)
end
end
if isEnum(class) then
printHeading(level + 1, "Values")
print()
end
end
local initial_level = 3
for name, module in sortedPairs(definitions_per_module) do
printHeading(initial_level, "`" .. name .. "`")
local top_level_class = module[name]
if top_level_class then
if top_level_class.desc then print(top_level_class.desc) end
for _, field in ipairs(top_level_class.fields or {}) do
emitField(initial_level + 1, name, field)
end
end
for _, class in sortedPairs(module) do
if class.name ~= name then
emitClass(initial_level + 1, name, class)
end
end
end

@ -0,0 +1,388 @@
--
-- json.lua
--
-- Copyright (c) 2020 rxi
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy of
-- this software and associated documentation files (the "Software"), to deal in
-- the Software without restriction, including without limitation the rights to
-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-- of the Software, and to permit persons to whom the Software is furnished to do
-- so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in all
-- copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
--
local json = { _version = "0.1.2" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\",
[ "\"" ] = "\"",
[ "\b" ] = "b",
[ "\f" ] = "f",
[ "\n" ] = "n",
[ "\r" ] = "r",
[ "\t" ] = "t",
}
local escape_char_map_inv = { [ "/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte()))
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if rawget(val, 1) ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
local line_count = 1
local col_count = 1
for i = 1, idx - 1 do
col_count = col_count + 1
if str:sub(i, i) == "\n" then
line_count = line_count + 1
col_count = 1
end
end
error( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(1, 4), 16 )
local n2 = tonumber( s:sub(7, 10), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local res = ""
local j = i + 1
local k = j
while j <= #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
elseif x == 92 then -- `\`: Escape
res = res .. str:sub(k, j - 1)
j = j + 1
local c = str:sub(j, j)
if c == "u" then
local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
or str:match("^%x%x%x%x", j + 1)
or decode_error(str, j - 1, "invalid unicode escape in string")
res = res .. parse_unicode_escape(hex)
j = j + #hex
else
if not escape_chars[c] then
decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
end
res = res .. escape_char_map_inv[c]
end
k = j + 1
elseif x == 34 then -- `"`: End of string
res = res .. str:sub(k, j - 1)
return res, j + 1
end
j = j + 1
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
local res, idx = parse(str, next_char(str, 1, space_chars, true))
idx = next_char(str, idx, space_chars, true)
if idx <= #str then
decode_error(str, idx, "trailing garbage")
end
return res
end
return json
Loading…
Cancel
Save