1
0
mirror of https://github.com/4yn/slidershim.git synced 2024-11-13 17:31:00 +01:00

Frontend polling

This commit is contained in:
4yn 2022-02-05 14:16:14 +08:00
parent bfb7f7dc95
commit 8a67094c9a
13 changed files with 394 additions and 103 deletions

View File

@ -0,0 +1,117 @@
<!DOCTYPE html>
<html>
<head>
<title>slidershim-brokenithm</title>
<meta charset="utf8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="favicon.ico" />
<style>
#fullscreen {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: #000000;
color: hotpink;
}
.container {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: flex;
flex-flow: column nowrap;
touch-action: none;
align-items: stretch;
}
.air-container {
/* display: flex; */
display: none;
flex-flow: column nowrap;
align-items: stretch;
flex: 1;
}
.touch-container {
display: flex;
flex-flow: row nowrap;
align-items: stretch;
flex: 1;
}
.grow > * {
flex: 1;
}
.key {
flex: 1;
border: 1px solid green;
}
.key[data-active] {
background-color: hotpink;
}
.key.air[data-active] {
background-color: skyblue;
}
canvas {
touch-action: none;
margin: 0px -1.5625vw;
}
</style>
</head>
<body>
<div id="fullscreen">
<!-- Offset for LED display -->
<div class="container">
<div class="air-container grow"></div>
<div class="touch-container grow">
<canvas id="canvas" width="33" height="1"></canvas>
</div>
</div>
<!-- Hitbox Divs -->
<div class="container" id="main">
<div class="air-container grow">
<div class="air key" data-air="1" data-kflag="5"></div>
<div class="air key" data-air="1" data-kflag="4"></div>
<div class="air key" data-air="1" data-kflag="3"></div>
<div class="air key" data-air="1" data-kflag="2"></div>
<div class="air key" data-air="1" data-kflag="1"></div>
<div class="air key" data-air="1" data-kflag="0"></div>
</div>
<div class="touch-container grow">
<div class="key" data-kflag="0"></div>
<div class="key" data-kflag="2"></div>
<div class="key" data-kflag="4"></div>
<div class="key" data-kflag="6"></div>
<div class="key" data-kflag="8"></div>
<div class="key" data-kflag="10"></div>
<div class="key" data-kflag="12"></div>
<div class="key" data-kflag="14"></div>
<div class="key" data-kflag="16"></div>
<div class="key" data-kflag="18"></div>
<div class="key" data-kflag="20"></div>
<div class="key" data-kflag="22"></div>
<div class="key" data-kflag="24"></div>
<div class="key" data-kflag="26"></div>
<div class="key" data-kflag="28"></div>
<div class="key" data-kflag="30"></div>
</div>
</div>
</div>
<script src="/config.js"></script>
<script src="/app.js"></script>
</body>
</html>

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>brokenithm-kb</title> <title>slidershim-brokenithm</title>
<meta charset="utf8" /> <meta charset="utf8" />
<meta <meta
name="viewport" name="viewport"

View File

@ -2,7 +2,7 @@ extern crate slidershim;
use std::io; use std::io;
use slidershim::slider_io::{Config, Manager}; use slidershim::slider_io::{Config, Context};
fn main() { fn main() {
env_logger::Builder::new() env_logger::Builder::new()
@ -57,7 +57,7 @@ fn main() {
// ) // )
// .unwrap(); // .unwrap();
let manager = Manager::new(config); let manager = Context::new(config);
let mut input = String::new(); let mut input = String::new();
let string = io::stdin().read_line(&mut input).unwrap(); let string = io::stdin().read_line(&mut input).unwrap();

View File

@ -18,10 +18,12 @@ use tauri::{
}; };
fn show_window<R: Runtime>(handle: &AppHandle<R>) { fn show_window<R: Runtime>(handle: &AppHandle<R>) {
handle.emit_all("ackShow", "");
handle.get_window("main").unwrap().show().ok(); handle.get_window("main").unwrap().show().ok();
} }
fn hide_window<R: Runtime>(handle: &AppHandle<R>) { fn hide_window<R: Runtime>(handle: &AppHandle<R>) {
handle.emit_all("ackHide", "");
handle.get_window("main").unwrap().hide().ok(); handle.get_window("main").unwrap().hide().ok();
} }
@ -36,14 +38,13 @@ fn main() {
.init(); .init();
let config = Arc::new(Mutex::new(Some(slider_io::Config::default()))); let config = Arc::new(Mutex::new(Some(slider_io::Config::default())));
let manager: Arc<Mutex<Option<slider_io::Manager>>> = Arc::new(Mutex::new(None)); let manager = Arc::new(Mutex::new(slider_io::Manager::new()));
{ {
let config_handle = config.lock().unwrap(); let config_handle = config.lock().unwrap();
let config_handle_ref = config_handle.as_ref().unwrap(); let config_handle_ref = config_handle.as_ref().unwrap();
config_handle_ref.save(); config_handle_ref.save();
// let mut manager_handle = manager.lock().unwrap(); let manager_handle = manager.lock().unwrap();
// manager_handle.take(); manager_handle.update_config(config_handle_ref.clone());
// manager_handle.replace(slider_io::Manager::new(config_handle_ref.clone()));
} }
tauri::Builder::default() tauri::Builder::default()
@ -95,12 +96,9 @@ fn main() {
// UI ready event // UI ready event
let app_handle = app.handle(); let app_handle = app.handle();
let config_clone = Arc::clone(&config); let config_clone = Arc::clone(&config);
app.listen_global("heartbeat", move |_| { app.listen_global("ready", move |_| {
let handle = AsyncHandle::try_current();
println!("handle, {:?}", handle);
let config_handle = config_clone.lock().unwrap(); let config_handle = config_clone.lock().unwrap();
info!("Heartbeat received"); info!("Start signal received");
app_handle app_handle
.emit_all( .emit_all(
"showConfig", "showConfig",
@ -109,6 +107,23 @@ fn main() {
.unwrap(); .unwrap();
}); });
// UI update event
let app_handle = app.handle();
let manager_clone = Arc::clone(&manager);
app.listen_global("queryState", move |event| {
// app_handle.emit_all("showState", "@@@");
let snapshot = {
let manager_handle = manager_clone.lock().unwrap();
manager_handle.try_get_state().map(|x| x.snapshot())
};
match snapshot {
Some(snapshot) => {
app_handle.emit_all("showState", snapshot);
}
_ => {}
}
});
// Config set event // Config set event
let config_clone = Arc::clone(&config); let config_clone = Arc::clone(&config);
let manager_clone = Arc::clone(&manager); let manager_clone = Arc::clone(&manager);
@ -121,9 +136,8 @@ fn main() {
config_handle.replace(new_config); config_handle.replace(new_config);
let config_handle_ref = config_handle.as_ref().unwrap(); let config_handle_ref = config_handle.as_ref().unwrap();
config_handle_ref.save(); config_handle_ref.save();
let mut manager_handle = manager_clone.lock().unwrap(); let manager_handle = manager_clone.lock().unwrap();
manager_handle.take(); manager_handle.update_config(config_handle_ref.clone());
manager_handle.replace(slider_io::Manager::new(config_handle_ref.clone()));
} }
}); });

View File

@ -9,7 +9,7 @@ use hyper::{
}; };
use log::{error, info}; use log::{error, info};
use path_clean::PathClean; use path_clean::PathClean;
use std::{convert::Infallible, future::Future, net::SocketAddr, path::PathBuf}; use std::{convert::Infallible, env::current_exe, future::Future, net::SocketAddr};
use tokio::fs::File; use tokio::fs::File;
use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::WebSocketStream;
use tokio_util::codec::{BytesCodec, FramedRead}; use tokio_util::codec::{BytesCodec, FramedRead};
@ -29,15 +29,17 @@ async fn error_response() -> Result<Response<Body>, Infallible> {
} }
async fn serve_file(path: &str) -> Result<Response<Body>, Infallible> { async fn serve_file(path: &str) -> Result<Response<Body>, Infallible> {
let mut pb = PathBuf::from("res/www/"); let mut pb = current_exe().unwrap();
pb.pop();
pb.push("res/www");
pb.push(path); pb.push(path);
pb.clean(); pb.clean();
// println!("CWD {:?}", std::env::current_dir()); // println!("CWD {:?}", std::env::current_dir());
// println!("Serving file {:?}", pb);
match File::open(pb).await { match File::open(&pb).await {
Ok(f) => { Ok(f) => {
info!("Serving file {:?}", pb);
let stream = FramedRead::new(f, BytesCodec::new()); let stream = FramedRead::new(f, BytesCodec::new());
let body = Body::wrap_stream(stream); let body = Body::wrap_stream(stream);
Ok(Response::new(body)) Ok(Response::new(body))
@ -154,6 +156,7 @@ async fn handle_request(
request: Request<Body>, request: Request<Body>,
remote_addr: SocketAddr, remote_addr: SocketAddr,
state: FullState, state: FullState,
ground_only: bool,
) -> Result<Response<Body>, Infallible> { ) -> Result<Response<Body>, Infallible> {
let method = request.method(); let method = request.method();
let path = request.uri().path(); let path = request.uri().path();
@ -170,7 +173,10 @@ async fn handle_request(
request.uri().path(), request.uri().path(),
request.headers().contains_key(header::UPGRADE), request.headers().contains_key(header::UPGRADE),
) { ) {
("/", false) | ("/index.html", false) => serve_file("index.html").await, ("/", false) | ("/index.html", false) => match ground_only {
false => serve_file("index.html").await,
true => serve_file("index-go.html").await,
},
(filename, false) => serve_file(&filename[1..]).await, (filename, false) => serve_file(&filename[1..]).await,
("/ws", true) => handle_websocket(request, state).await, ("/ws", true) => handle_websocket(request, state).await,
_ => error_response().await, _ => error_response().await,
@ -179,12 +185,14 @@ async fn handle_request(
pub struct BrokenithmJob { pub struct BrokenithmJob {
state: FullState, state: FullState,
ground_only: bool,
} }
impl BrokenithmJob { impl BrokenithmJob {
pub fn new(state: &FullState) -> Self { pub fn new(state: &FullState, ground_only: &bool) -> Self {
Self { Self {
state: state.clone(), state: state.clone(),
ground_only: *ground_only,
} }
} }
} }
@ -193,13 +201,14 @@ impl BrokenithmJob {
impl AsyncJob for BrokenithmJob { impl AsyncJob for BrokenithmJob {
async fn run<F: Future<Output = ()> + Send>(self, stop_signal: F) { async fn run<F: Future<Output = ()> + Send>(self, stop_signal: F) {
let state = self.state.clone(); let state = self.state.clone();
let ground_only = self.ground_only;
let make_svc = make_service_fn(|conn: &AddrStream| { let make_svc = make_service_fn(|conn: &AddrStream| {
let remote_addr = conn.remote_addr(); let remote_addr = conn.remote_addr();
let make_svc_state = state.clone(); let make_svc_state = state.clone();
async move { async move {
Ok::<_, Infallible>(service_fn(move |request: Request<Body>| { Ok::<_, Infallible>(service_fn(move |request: Request<Body>| {
let svc_state = make_svc_state.clone(); let svc_state = make_svc_state.clone();
handle_request(request, remote_addr, svc_state) handle_request(request, remote_addr, svc_state, ground_only)
})) }))
} }
}); });

View File

@ -0,0 +1,64 @@
use log::info;
use crate::slider_io::{
brokenithm::BrokenithmJob,
config::{Config, DeviceMode},
controller_state::FullState,
device::HidDeviceJob,
led::LedJob,
output::OutputJob,
worker::{AsyncWorker, ThreadWorker},
};
#[allow(dead_code)]
pub struct Context {
state: FullState,
config: Config,
device_worker: Option<ThreadWorker>,
brokenithm_worker: Option<AsyncWorker>,
output_worker: ThreadWorker,
led_worker: ThreadWorker,
}
impl Context {
pub fn new(config: Config) -> Self {
info!("Context creating");
info!("Device config {:?}", config.device_mode);
info!("Output config {:?}", config.output_mode);
info!("LED config {:?}", config.led_mode);
let state = FullState::new();
let (device_worker, brokenithm_worker) = match &config.device_mode {
DeviceMode::Brokenithm { ground_only } => (
None,
Some(AsyncWorker::new(
"brokenithm",
BrokenithmJob::new(&state, ground_only),
)),
),
other => (
Some(ThreadWorker::new(
"device",
HidDeviceJob::from_config(&state, other),
)),
None,
),
};
let output_worker = ThreadWorker::new("output", OutputJob::new(&state, &config.output_mode));
let led_worker = ThreadWorker::new("led", LedJob::new(&state, &config.led_mode));
Self {
state,
config,
device_worker,
brokenithm_worker,
output_worker,
led_worker,
}
}
pub fn clone_state(&self) -> FullState {
self.state.clone()
}
}

View File

@ -74,6 +74,21 @@ impl FullState {
pub fn clone_led(&self) -> Arc<Mutex<LedState>> { pub fn clone_led(&self) -> Arc<Mutex<LedState>> {
Arc::clone(&self.led_state) Arc::clone(&self.led_state)
} }
pub fn snapshot(&self) -> Vec<u8> {
let mut buf: Vec<u8> = vec![];
{
let controller_state_handle = self.controller_state.lock().unwrap();
buf.extend(controller_state_handle.ground_state);
buf.extend(controller_state_handle.air_state);
};
{
let led_state_handle = self.led_state.lock().unwrap();
buf.extend(led_state_handle.led_state);
};
buf
}
} }
impl Clone for FullState { impl Clone for FullState {

View File

@ -31,8 +31,8 @@ const YUANCON_KB_MAP: [usize; 41] = [
#[rustfmt::skip] #[rustfmt::skip]
const DEEMO_KB_MAP: [usize; 41] = [ const DEEMO_KB_MAP: [usize; 41] = [
0x41, 0x41, 0x41, 0x41, // A 0x41, 0x41, 0x41, 0x41, // A
0x35, 0x35, 0x35, 0x35, // S 0x53, 0x53, 0x53, 0x53, // S
0x4f, 0x4f, 0x4f, 0x4f, // D 0x44, 0x44, 0x44, 0x44, // D
0x46, 0x46, 0x46, 0x46, // F 0x46, 0x46, 0x46, 0x46, // F
0x4a, 0x4a, 0x4a, 0x4a, // J 0x4a, 0x4a, 0x4a, 0x4a, // J
0x4b, 0x4b, 0x4b, 0x4b, // K 0x4b, 0x4b, 0x4b, 0x4b, // K

View File

@ -1,57 +1,94 @@
use log::info; use log::info;
use std::{
use crate::slider_io::{ sync::{Arc, Mutex},
brokenithm::BrokenithmJob, thread::{self, JoinHandle},
config::{Config, DeviceMode}, };
controller_state::FullState, use tokio::{
device::HidDeviceJob, select,
led::LedJob, sync::{mpsc, oneshot},
output::OutputJob,
worker::{AsyncWorker, ThreadWorker},
}; };
#[allow(dead_code)] use crate::slider_io::{config::Config, context::Context};
use super::controller_state::FullState;
pub struct Manager { pub struct Manager {
state: FullState, state: Arc<Mutex<Option<FullState>>>,
config: Config, join_handle: Option<JoinHandle<()>>,
device_worker: Option<ThreadWorker>, tx_config: mpsc::UnboundedSender<Config>,
brokenithm_worker: Option<AsyncWorker>, tx_stop: Option<oneshot::Sender<()>>,
output_worker: ThreadWorker,
led_worker: ThreadWorker,
} }
impl Manager { impl Manager {
pub fn new(config: Config) -> Self { pub fn new() -> Self {
info!("Starting manager"); let state = Arc::new(Mutex::new(None));
info!("Device config {:?}", config.device_mode); let (tx_config, mut rx_config) = mpsc::unbounded_channel::<Config>();
info!("Output config {:?}", config.output_mode); let (tx_stop, rx_stop) = oneshot::channel::<()>();
info!("LED config {:?}", config.led_mode);
let state = FullState::new(); let context: Arc<Mutex<Option<Context>>> = Arc::new(Mutex::new(None));
let (device_worker, brokenithm_worker) = match &config.device_mode { let state_cloned = Arc::clone(&state);
DeviceMode::Brokenithm { .. } => ( let context_cloned = Arc::clone(&context);
None,
Some(AsyncWorker::new("brokenithm", BrokenithmJob::new(&state))), let join_handle = thread::spawn(move || {
), info!("Manager thread started");
other => ( let runtime = tokio::runtime::Builder::new_multi_thread()
Some(ThreadWorker::new( .worker_threads(1)
"device", .enable_all()
HidDeviceJob::from_config(&state, other), .build()
)), .unwrap();
None, runtime.block_on(async move {
), info!("Manager runtime started");
};
let output_worker = ThreadWorker::new("output", OutputJob::new(&state, &config.output_mode)); select! {
let led_worker = ThreadWorker::new("led", LedJob::new(&state, &config.led_mode)); _ = async {
loop {
match rx_config.recv().await {
Some(config) => {
info!("Rebuilding context");
let mut context_handle = context_cloned.lock().unwrap();
context_handle.take();
let new_context = Context::new(config);
let new_state = new_context.clone_state();
context_handle.replace(new_context);
let mut state_handle = state_cloned.lock().unwrap();
state_handle.replace(new_state);
},
None => {
let mut context_handle = context_cloned.lock().unwrap();
context_handle.take();
}
}
}
} => {},
_ = rx_stop => {}
}
});
});
Self { Self {
state, state,
config, join_handle: Some(join_handle),
device_worker, tx_config,
brokenithm_worker, tx_stop: Some(tx_stop),
output_worker,
led_worker,
} }
} }
pub fn update_config(&self, config: Config) {
self.tx_config.send(config).unwrap();
}
pub fn try_get_state(&self) -> Option<FullState> {
let state_handle = self.state.lock().unwrap();
state_handle.as_ref().map(|x| x.clone())
}
}
impl Drop for Manager {
fn drop(&mut self) {
self.tx_stop.take().unwrap().send(()).unwrap();
self.join_handle.take().unwrap().join().unwrap();
}
} }

View File

@ -13,6 +13,7 @@ mod device;
mod led; mod led;
mod output; mod output;
mod context;
mod manager; mod manager;
pub use config::Config; pub use config::Config;

View File

@ -9,7 +9,7 @@ use std::{
thread, thread,
}; };
use tokio::{runtime::Runtime, sync::oneshot, task}; use tokio::{sync::oneshot, task};
pub trait ThreadJob: Send { pub trait ThreadJob: Send {
fn setup(&mut self) -> bool; fn setup(&mut self) -> bool;
@ -68,7 +68,6 @@ pub trait AsyncJob: Send + 'static {
pub struct AsyncWorker { pub struct AsyncWorker {
name: &'static str, name: &'static str,
runtime: Runtime,
task: Option<task::JoinHandle<()>>, task: Option<task::JoinHandle<()>>,
stop_signal: Option<oneshot::Sender<()>>, stop_signal: Option<oneshot::Sender<()>>,
} }
@ -81,13 +80,8 @@ impl AsyncWorker {
info!("Async worker starting {}", name); info!("Async worker starting {}", name);
let (send_stop, recv_stop) = oneshot::channel::<()>(); let (send_stop, recv_stop) = oneshot::channel::<()>();
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
let task = runtime.spawn(async move { let task = tokio::spawn(async move {
job job
.run(async move { .run(async move {
recv_stop.await.unwrap(); recv_stop.await.unwrap();
@ -97,7 +91,6 @@ impl AsyncWorker {
AsyncWorker { AsyncWorker {
name, name,
runtime,
task: Some(task), task: Some(task),
stop_signal: Some(send_stop), stop_signal: Some(send_stop),
} }
@ -108,20 +101,7 @@ impl Drop for AsyncWorker {
fn drop(&mut self) { fn drop(&mut self) {
info!("Async worker stopping {}", self.name); info!("Async worker stopping {}", self.name);
if self.stop_signal.is_some() { self.stop_signal.take().unwrap().send(()).unwrap();
let send_stop = self.stop_signal.take().unwrap(); self.task.take();
send_stop.send(()).unwrap();
// self.runtime.block_on(async move {
// send_stop.send(()).unwrap();
// });
}
if self.task.is_some() {
// let task = self.task.take().unwrap();
// self.runtime.block_on(async move {
// task.await;
// info!("Async worker stopping internal {}", name);
// });
}
} }
} }

View File

@ -16,10 +16,27 @@
let debugstr = ""; let debugstr = "";
let polling = null;
let tick = 0;
let previewData = Array(131).fill(0);
function updatePolling(enabled) {
if (!!polling) {
clearInterval(polling);
polling = null;
}
if (enabled) {
polling = setInterval(async () => {
tick += 1;
await emit("queryState", "");
}, 50);
}
// console.log(enabled, polling, tick);
}
onMount(async () => { onMount(async () => {
// console.log(emit, listen); // console.log(emit, listen);
await listen("showConfig", (event) => { await listen("showConfig", (event) => {
console.log("heartbeat", event);
debugstr = event.payload; debugstr = event.payload;
const payload: any = JSON.parse(event.payload as any); const payload: any = JSON.parse(event.payload as any);
deviceMode = payload.deviceMode || "none"; deviceMode = payload.deviceMode || "none";
@ -32,7 +49,22 @@
ledWebsocketUrl = payload.ledWebsocketUrl || "http://localhost:3001"; ledWebsocketUrl = payload.ledWebsocketUrl || "http://localhost:3001";
ledSerialPort = payload.ledSerialPort || "COM5"; ledSerialPort = payload.ledSerialPort || "COM5";
}); });
await emit("heartbeat", "");
await listen("showState", (event) => {
previewData = event.payload;
});
await emit("ready", "");
updatePolling(true);
await listen("ackShow", (event) => {
console.log("ackShow");
updatePolling(true);
});
await listen("ackHide", (event) => {
console.log("ackHide");
updatePolling(false);
});
}); });
async function setConfig() { async function setConfig() {
@ -73,7 +105,7 @@
{debugstr} {debugstr}
</div> --> </div> -->
<div class="row"> <div class="row">
<Preview /> <Preview data={previewData} />
</div> </div>
<div class="row"> <div class="row">
<div class="label">Input Device</div> <div class="label">Input Device</div>
@ -84,7 +116,7 @@
<option value="tasoller-two">GAMO2 Tasoller, 2.0 HID Firmware</option> <option value="tasoller-two">GAMO2 Tasoller, 2.0 HID Firmware</option>
<option value="yuancon">Yuancon Laverita, HID Firmware</option> <option value="yuancon">Yuancon Laverita, HID Firmware</option>
<option value="brokenithm">Brokenithm</option> <option value="brokenithm">Brokenithm</option>
<option value="brokenithm-ground">Brokenithm, Ground only</option> <option value="brokenithm-ground">Brokenithm, Ground only (WIP)</option>
</select> </select>
</div> </div>
</div> </div>

View File

@ -1,22 +1,44 @@
<script lang="ts"> <script lang="ts">
export let data: Array<number>;
let topDatas = Array(16).fill(0); let topDatas = Array(16).fill(0);
let botDatas = Array(16).fill(0); let botDatas = Array(16).fill(0);
let ledDatas = Array(31).fill(0).map((_, idx) => ({ let ledDatas = Array(31)
color: !!(idx % 2) ? "f0f" : "ff0", .fill(0)
spec: idx % 2 .map((_, idx) => ({
})) color: !!(idx % 2) ? "#f0f" : "#ff0",
spec: idx % 2,
}));
$: {
if (data.length === 131) {
// console.log(data);
for (let i = 0; i < 16; i++) {
topDatas[i] = data[i * 2 + 1];
botDatas[i] = data[i * 2];
}
for (let i = 0; i < 31; i++) {
ledDatas[i].color = `rgb(${data[38 + i * 3]}, ${data[39 + i * 3]}, ${
data[40 + i * 3]
})`;
}
}
}
</script> </script>
<main class="preview"> <main class="preview">
<div class="air"></div> <div class="air" />
<div class="ground"> <div class="ground">
<div class="ground-led"> <div class="ground-led">
<div class="ground-row"> <div class="ground-row">
<div class="ground-led-2"></div> <div class="ground-led-2" />
{#each ledDatas as {color, spec}, idx (idx)} {#each ledDatas as { color, spec }, idx (idx)}
<div class={`ground-led-${spec}`} style={`background-color: #${color}`}></div> <div
class={`ground-led-${spec}`}
style={`background-color: ${color}`}
/>
{/each} {/each}
<div class="ground-led-2"></div> <div class="ground-led-2" />
</div> </div>
</div> </div>
<div class="ground-btn"> <div class="ground-btn">