infinicode 2.8.98 → 2.8.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +225 -51
- package/dist/robopark/robot-runtime.js +6 -0
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +16 -1
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +20 -0
- package/packages/robopark/scheduler/preview_agent.py +71 -8
- package/packages/robopark/scheduler/robot_supervisor.py +21 -0
|
@@ -4079,25 +4079,24 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
4079
4079
|
var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};
|
|
4080
4080
|
// Street traffic along the top road. Each car has x, y (world), color,
|
|
4081
4081
|
// a per-car speed (world units / second), a direction (+1 = right, -1 =
|
|
4082
|
-
// left)
|
|
4083
|
-
//
|
|
4084
|
-
//
|
|
4085
|
-
//
|
|
4086
|
-
// it wraps to the opposite side with a small y jitter so the flow
|
|
4087
|
-
// doesn't visibly reset.
|
|
4082
|
+
// left). y is one of two lane bands (≈ -0.65 for outbound, ≈ -1.18 for
|
|
4083
|
+
// inbound) so cars don't overlap. Speeds are deliberately varied and
|
|
4084
|
+
// fast enough to read as movement at 60fps — the road is short, so
|
|
4085
|
+
// a 4-7s pass across the screen feels natural.
|
|
4088
4086
|
var ROAD_Y0=-1.5, ROAD_Y1=-0.35;
|
|
4089
4087
|
var CARS=[
|
|
4090
|
-
{x:
|
|
4091
|
-
{x:
|
|
4092
|
-
{x:
|
|
4093
|
-
{x:
|
|
4094
|
-
{x:11.
|
|
4095
|
-
{x:
|
|
4096
|
-
{x:13.
|
|
4097
|
-
{x:
|
|
4088
|
+
{x:1.5, y:-0.65, color:'#f2c14e', speed:3.2, dir:+1},
|
|
4089
|
+
{x:4.0, y:-1.18, color:'#7fb4ff', speed:3.6, dir:-1},
|
|
4090
|
+
{x:6.5, y:-0.65, color:'#e0a23c', speed:2.8, dir:+1},
|
|
4091
|
+
{x:9.0, y:-1.18, color:'#ff7e6e', speed:4.0, dir:-1},
|
|
4092
|
+
{x:11.5, y:-0.65, color:'#d8d8e0', speed:3.0, dir:+1},
|
|
4093
|
+
{x:0.5, y:-1.18, color:'#5b8bd6', speed:3.4, dir:-1},
|
|
4094
|
+
{x:13.0, y:-0.65, color:'#c87ed4', speed:2.6, dir:+1},
|
|
4095
|
+
{x:7.0, y:-1.18, color:'#f2c14e', speed:3.8, dir:-1},
|
|
4098
4096
|
];
|
|
4099
|
-
// Per-car phase offsets so
|
|
4100
|
-
// in lockstep — gives a natural "
|
|
4097
|
+
// Per-car phase offsets so headlights / taillights / wheels don't all
|
|
4098
|
+
// blink in lockstep — gives a natural "different cars, different
|
|
4099
|
+
// speeds" feel.
|
|
4101
4100
|
var _carPhase = CARS.map(function(_, i){ return i * 0.7; });
|
|
4102
4101
|
// ── Walking people — looping sprites that stroll along fixed plaza paths.
|
|
4103
4102
|
// Each walker is a small canvas-drawn figure (head + body + two
|
|
@@ -4497,37 +4496,24 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
4497
4496
|
if(typeof now !== 'number') now = performance.now();
|
|
4498
4497
|
var _now = now;
|
|
4499
4498
|
var z=cam.z;
|
|
4500
|
-
// ── grass base —
|
|
4501
|
-
//
|
|
4502
|
-
//
|
|
4503
|
-
//
|
|
4499
|
+
// ── grass base — single uniform mid-green, no radial brightening ──
|
|
4500
|
+
// Park sits on a floating island now, so we want a flat, calm
|
|
4501
|
+
// grass tone across the whole field. The previous radial gradient
|
|
4502
|
+
// pushed a lighter green toward the center which read as a "spot"
|
|
4503
|
+
// — replaced with a single soft fill so the island feels like a
|
|
4504
|
+
// single piece of turf.
|
|
4504
4505
|
var parkCx=PARK_W/2, parkCy=PARK_D/2;
|
|
4505
4506
|
var gCent=iso(parkCx,parkCy);
|
|
4506
4507
|
var gRad=X.createRadialGradient(gCent.x,gCent.y,12*z,gCent.x,gCent.y,Math.max(W,H)*0.62);
|
|
4507
|
-
gRad.addColorStop(0,'rgba(
|
|
4508
|
-
gRad.addColorStop(0.
|
|
4509
|
-
gRad.addColorStop(1,'rgba(
|
|
4508
|
+
gRad.addColorStop(0,'rgba(94,150,84,0.95)');
|
|
4509
|
+
gRad.addColorStop(0.5,'rgba(86,140,76,0.95)');
|
|
4510
|
+
gRad.addColorStop(1,'rgba(78,128,68,0.95)');
|
|
4510
4511
|
// grass field — tight margin around the plaza + lot, NOT covering the road or the beach
|
|
4511
4512
|
var GF_X0=0.0, GF_X1=PARK_W, GF_Y0=-0.32, GF_Y1=PARK_D-0.55;
|
|
4512
4513
|
poly([[GF_X0,GF_Y0],[GF_X1,GF_Y0],[GF_X1,GF_Y1],[GF_X0,GF_Y1]], gRad, 'rgba(140,200,150,0.10)',1);
|
|
4513
|
-
//
|
|
4514
|
-
//
|
|
4515
|
-
|
|
4516
|
-
var stripeColors=['rgba(108,162,90,0.45)','rgba(86,140,72,0.35)'];
|
|
4517
|
-
var stripeCount=5;
|
|
4518
|
-
var stripeW=(GF_X1-GF_X0+GF_Y1-GF_Y0+2)/stripeCount;
|
|
4519
|
-
for(var si=0;si<stripeCount;si++){
|
|
4520
|
-
var off=si*stripeW - ((GF_X1-GF_X0)+(GF_Y1-GF_Y0))*0.5;
|
|
4521
|
-
// Build a parallelogram in world coords that runs from left-bottom to right-top
|
|
4522
|
-
// at a slight angle (skewed +X, -Y direction) — looks like a stadium mowed lawn.
|
|
4523
|
-
// Tightly fitted to the grass field bounds.
|
|
4524
|
-
var p1=[GF_X0 + off, GF_Y1];
|
|
4525
|
-
var p2=[GF_X0 + off + 1.0, GF_Y0];
|
|
4526
|
-
var p3=[GF_X0 + off + 1.0 + (GF_X1-GF_X0), GF_Y0];
|
|
4527
|
-
var p4=[GF_X0 + off + (GF_X1-GF_X0), GF_Y1];
|
|
4528
|
-
poly([p1,p2,p3,p4], stripeColors[si%2], null, 0);
|
|
4529
|
-
}
|
|
4530
|
-
X.restore();
|
|
4514
|
+
// (mowed-stripe effect removed — was the lighter green alternating
|
|
4515
|
+
// bands on the sides of the park. Keeping the lawn as a single
|
|
4516
|
+
// uniform tone so the park reads as a clean floating island.)
|
|
4531
4517
|
// ── outer hedge / foliage border just inside the grass field ──
|
|
4532
4518
|
// Slightly ragged inner edge: extra points along each side to break the straight line.
|
|
4533
4519
|
// The hedge wraps the grass field, which now hugs the plaza footprint (no side overhang).
|
|
@@ -4598,16 +4584,197 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
4598
4584
|
X.closePath();
|
|
4599
4585
|
X.strokeStyle='rgba(96,148,86,0.7)'; X.lineWidth=0.8*z; X.stroke();
|
|
4600
4586
|
X.restore();
|
|
4601
|
-
// grass noise — scattered
|
|
4587
|
+
// grass noise — scattered darker tufts for texture (no lighter
|
|
4588
|
+
// tufts, so the field stays a single uniform mid-green)
|
|
4602
4589
|
X.save();
|
|
4603
|
-
for(var ni=0;ni<
|
|
4590
|
+
for(var ni=0;ni<40;ni++){
|
|
4604
4591
|
var gnx=GF_X0 + ((ni*37)%100)/100*(GF_X1-GF_X0);
|
|
4605
4592
|
var gny=GF_Y0 + ((ni*53)%100)/100*(GF_Y1-GF_Y0);
|
|
4606
4593
|
var gp=iso(gnx,gny);
|
|
4607
|
-
X.fillStyle=
|
|
4594
|
+
X.fillStyle='rgba(56,112,62,0.22)';
|
|
4608
4595
|
X.beginPath(); X.arc(gp.x+((ni*7)%13-6)*z,gp.y+((ni*11)%9-4)*z,1.6*z,0,7); X.fill();
|
|
4609
4596
|
}
|
|
4610
4597
|
X.restore();
|
|
4598
|
+
// ── GOLD BORDER + FLOATING ISLAND DEPTH ──
|
|
4599
|
+
// The whole park now reads as a single floating slab of turf in
|
|
4600
|
+
// the sky. We render (in world space, around the grass field):
|
|
4601
|
+
// 1. a soft drop-shadow ellipse on the sky below the island
|
|
4602
|
+
// 2. a tapered rocky underside (darker, hanging from the edge)
|
|
4603
|
+
// 3. a thick gold rim tracing the grass perimeter
|
|
4604
|
+
// 4. a thin warm-glow outer ring (sells the gold as "lit by
|
|
4605
|
+
// the warm sun")
|
|
4606
|
+
// The underside and drop shadow give the park real depth so it
|
|
4607
|
+
// doesn't look like a flat sticker on the sky.
|
|
4608
|
+
// Build the grass perimeter polygon once (offset slightly outward
|
|
4609
|
+
// so the gold rim sits just outside the turf edge).
|
|
4610
|
+
var GRASS_RIM = 0.18; // world units of gold rim thickness
|
|
4611
|
+
function grassPerim(offset){
|
|
4612
|
+
// Use the same world corners as the grass field, offset outward.
|
|
4613
|
+
var o = offset;
|
|
4614
|
+
return [
|
|
4615
|
+
[GF_X0-o, GF_Y0-o],
|
|
4616
|
+
[GF_X1+o, GF_Y0-o],
|
|
4617
|
+
[GF_X1+o, GF_Y1+o],
|
|
4618
|
+
[GF_X0-o, GF_Y1+o]
|
|
4619
|
+
];
|
|
4620
|
+
}
|
|
4621
|
+
// 1. drop shadow on the sky — a wide soft ellipse below the
|
|
4622
|
+
// island's bottom edge (in screen space, large + soft).
|
|
4623
|
+
var botL = iso(GF_X0, GF_Y1);
|
|
4624
|
+
var botR = iso(GF_X1, GF_Y1);
|
|
4625
|
+
var botM = { x:(botL.x+botR.x)/2, y:(botL.y+botR.y)/2 };
|
|
4626
|
+
var islandWidth = Math.abs(botR.x - botL.x);
|
|
4627
|
+
var shadowCx = botM.x;
|
|
4628
|
+
var shadowCy = botM.y + 28*z;
|
|
4629
|
+
var shadowRX = islandWidth * 0.55;
|
|
4630
|
+
var shadowRY = 22 * z;
|
|
4631
|
+
var dropShadow = X.createRadialGradient(shadowCx, shadowCy, 0, shadowCx, shadowCy, shadowRX);
|
|
4632
|
+
dropShadow.addColorStop(0, 'rgba(20, 24, 36, 0.55)');
|
|
4633
|
+
dropShadow.addColorStop(0.5, 'rgba(20, 24, 36, 0.25)');
|
|
4634
|
+
dropShadow.addColorStop(1, 'rgba(20, 24, 36, 0)');
|
|
4635
|
+
X.fillStyle = dropShadow;
|
|
4636
|
+
X.beginPath();
|
|
4637
|
+
X.ellipse(shadowCx, shadowCy, shadowRX, shadowRY, 0, 0, Math.PI*2);
|
|
4638
|
+
X.fill();
|
|
4639
|
+
// 2. rocky underside — draw the grass field polygon again, but
|
|
4640
|
+
// offset down by ~14*z and slightly darker, then close it into
|
|
4641
|
+
// a wedge. We do this with two filled paths:
|
|
4642
|
+
// - the bottom face: same perimeter polygon shifted down
|
|
4643
|
+
// - the side rim: a band that traces from the top of the
|
|
4644
|
+
// bottom face up to the top of the island (so the side
|
|
4645
|
+
// of the slab reads as dirt/rock)
|
|
4646
|
+
var UNDERSIDE_LIFT = 16 * z;
|
|
4647
|
+
var topPerim = grassPerim(0);
|
|
4648
|
+
var botPerim = topPerim.map(function(p){ return [p[0], p[1] + UNDERSIDE_LIFT / (TH*z) * (1/22)]; });
|
|
4649
|
+
// Convert botPerim into screen-space points (slightly down from top).
|
|
4650
|
+
var topScreen = topPerim.map(function(p){ return iso(p[0], p[1]); });
|
|
4651
|
+
var botScreen = topPerim.map(function(p){ return iso(p[0], p[1] + 1.0); });
|
|
4652
|
+
// right side face
|
|
4653
|
+
X.save();
|
|
4654
|
+
X.fillStyle = 'rgba(38, 50, 36, 0.92)';
|
|
4655
|
+
X.beginPath();
|
|
4656
|
+
X.moveTo(topScreen[1].x, topScreen[1].y);
|
|
4657
|
+
X.lineTo(topScreen[2].x, topScreen[2].y);
|
|
4658
|
+
X.lineTo(botScreen[2].x, botScreen[2].y + UNDERSIDE_LIFT);
|
|
4659
|
+
X.lineTo(botScreen[1].x, botScreen[1].y + UNDERSIDE_LIFT);
|
|
4660
|
+
X.closePath();
|
|
4661
|
+
X.fill();
|
|
4662
|
+
// bottom-right edge highlight (slight bevel)
|
|
4663
|
+
X.strokeStyle = 'rgba(80, 96, 60, 0.55)';
|
|
4664
|
+
X.lineWidth = 1.0*z;
|
|
4665
|
+
X.beginPath();
|
|
4666
|
+
X.moveTo(topScreen[2].x, topScreen[2].y);
|
|
4667
|
+
X.lineTo(botScreen[2].x, botScreen[2].y + UNDERSIDE_LIFT);
|
|
4668
|
+
X.stroke();
|
|
4669
|
+
// left side face
|
|
4670
|
+
X.fillStyle = 'rgba(28, 38, 26, 0.95)';
|
|
4671
|
+
X.beginPath();
|
|
4672
|
+
X.moveTo(topScreen[0].x, topScreen[0].y);
|
|
4673
|
+
X.lineTo(topScreen[1].x, topScreen[1].y);
|
|
4674
|
+
X.lineTo(botScreen[1].x, botScreen[1].y + UNDERSIDE_LIFT);
|
|
4675
|
+
X.lineTo(botScreen[0].x, botScreen[0].y + UNDERSIDE_LIFT);
|
|
4676
|
+
X.closePath();
|
|
4677
|
+
X.fill();
|
|
4678
|
+
// bottom-left edge highlight
|
|
4679
|
+
X.strokeStyle = 'rgba(70, 84, 52, 0.45)';
|
|
4680
|
+
X.beginPath();
|
|
4681
|
+
X.moveTo(topScreen[0].x, topScreen[0].y);
|
|
4682
|
+
X.lineTo(botScreen[0].x, botScreen[0].y + UNDERSIDE_LIFT);
|
|
4683
|
+
X.stroke();
|
|
4684
|
+
// bottom face — slightly tapered into a rough point so the slab
|
|
4685
|
+
// doesn't look like a flat box. We taper the bottom by ~25%.
|
|
4686
|
+
var tapered = topScreen.map(function(s, i){
|
|
4687
|
+
if(i === 0) return { x: s.x + 40*z, y: s.y + UNDERSIDE_LIFT*0.95 };
|
|
4688
|
+
if(i === 1) return { x: s.x - 40*z, y: s.y + UNDERSIDE_LIFT*0.95 };
|
|
4689
|
+
if(i === 2) return { x: s.x - 60*z, y: s.y + UNDERSIDE_LIFT*0.85 };
|
|
4690
|
+
if(i === 3) return { x: s.x + 60*z, y: s.y + UNDERSIDE_LIFT*0.85 };
|
|
4691
|
+
return { x: s.x, y: s.y + UNDERSIDE_LIFT };
|
|
4692
|
+
});
|
|
4693
|
+
X.fillStyle = 'rgba(20, 28, 20, 0.92)';
|
|
4694
|
+
X.beginPath();
|
|
4695
|
+
X.moveTo(topScreen[0].x, topScreen[0].y);
|
|
4696
|
+
X.lineTo(topScreen[1].x, topScreen[1].y);
|
|
4697
|
+
X.lineTo(tapered[1].x, tapered[1].y);
|
|
4698
|
+
X.lineTo(tapered[2].x, tapered[2].y);
|
|
4699
|
+
X.lineTo(tapered[3].x, tapered[3].y);
|
|
4700
|
+
X.lineTo(tapered[0].x, tapered[0].y);
|
|
4701
|
+
X.closePath();
|
|
4702
|
+
X.fill();
|
|
4703
|
+
// a few rocky stalactite bumps hanging from the underside so it
|
|
4704
|
+
// doesn't look like a perfect box either
|
|
4705
|
+
var bumpSpots = [
|
|
4706
|
+
{ px: 0.18, len: 0.65 },
|
|
4707
|
+
{ px: 0.34, len: 0.85 },
|
|
4708
|
+
{ px: 0.52, len: 0.55 },
|
|
4709
|
+
{ px: 0.68, len: 0.95 },
|
|
4710
|
+
{ px: 0.86, len: 0.70 }
|
|
4711
|
+
];
|
|
4712
|
+
for(var bpi=0; bpi<bumpSpots.length; bpi++){
|
|
4713
|
+
var bx = GF_X0 + bumpSpots[bpi].px * (GF_X1-GF_X0);
|
|
4714
|
+
var by = GF_Y1;
|
|
4715
|
+
var bp = iso(bx, by);
|
|
4716
|
+
var bpLen = bumpSpots[bpi].len * UNDERSIDE_LIFT;
|
|
4717
|
+
X.fillStyle = 'rgba(24, 32, 22, 0.88)';
|
|
4718
|
+
X.beginPath();
|
|
4719
|
+
X.moveTo(bp.x - 14*z, bp.y + 2*z);
|
|
4720
|
+
// 2-sided stalactite that tapers to a point
|
|
4721
|
+
X.quadraticCurveTo(bp.x, bp.y + bpLen*0.5, bp.x, bp.y + bpLen);
|
|
4722
|
+
X.quadraticCurveTo(bp.x, bp.y + bpLen*0.5, bp.x + 14*z, bp.y + 2*z);
|
|
4723
|
+
X.closePath();
|
|
4724
|
+
X.fill();
|
|
4725
|
+
}
|
|
4726
|
+
X.restore();
|
|
4727
|
+
// 3. GOLD RIM — thick gold band tracing the grass perimeter.
|
|
4728
|
+
// Drawn AFTER the underside so the top edge of the gold
|
|
4729
|
+
// sits flush with the top of the grass. Two strokes: a wide
|
|
4730
|
+
// gold core + a thin lighter inner highlight on the top edge.
|
|
4731
|
+
var rimOuter = grassPerim(GRASS_RIM);
|
|
4732
|
+
var rimInner = grassPerim(0);
|
|
4733
|
+
var rimOuterScreen = rimOuter.map(function(p){ return iso(p[0], p[1]); });
|
|
4734
|
+
var rimInnerScreen = rimInner.map(function(p){ return iso(p[0], p[1]); });
|
|
4735
|
+
X.save();
|
|
4736
|
+
// outer glow
|
|
4737
|
+
var glowGrad = X.createLinearGradient(0, rimOuterScreen[0].y - 30, 0, rimOuterScreen[3].y + 30);
|
|
4738
|
+
X.fillStyle = 'rgba(255, 200, 110, 0.0)';
|
|
4739
|
+
// fill the rim band as a quad
|
|
4740
|
+
X.beginPath();
|
|
4741
|
+
X.moveTo(rimOuterScreen[0].x, rimOuterScreen[0].y);
|
|
4742
|
+
X.lineTo(rimOuterScreen[1].x, rimOuterScreen[1].y);
|
|
4743
|
+
X.lineTo(rimOuterScreen[2].x, rimOuterScreen[2].y);
|
|
4744
|
+
X.lineTo(rimOuterScreen[3].x, rimOuterScreen[3].y);
|
|
4745
|
+
X.closePath();
|
|
4746
|
+
X.fill();
|
|
4747
|
+
// gold band via two strokes: thick base + thin highlight
|
|
4748
|
+
X.strokeStyle = 'rgba(255, 196, 92, 0.95)'; // base gold
|
|
4749
|
+
X.lineWidth = 5.0*z;
|
|
4750
|
+
X.beginPath();
|
|
4751
|
+
X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
|
|
4752
|
+
X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
|
|
4753
|
+
X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
|
|
4754
|
+
X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
|
|
4755
|
+
X.closePath();
|
|
4756
|
+
X.stroke();
|
|
4757
|
+
// inner highlight (thinner, brighter)
|
|
4758
|
+
X.strokeStyle = 'rgba(255, 232, 158, 0.95)';
|
|
4759
|
+
X.lineWidth = 1.4*z;
|
|
4760
|
+
X.beginPath();
|
|
4761
|
+
X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
|
|
4762
|
+
X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
|
|
4763
|
+
X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
|
|
4764
|
+
X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
|
|
4765
|
+
X.closePath();
|
|
4766
|
+
X.stroke();
|
|
4767
|
+
// outer deep-gold edge (slightly darker outer stroke for definition)
|
|
4768
|
+
X.strokeStyle = 'rgba(190, 134, 48, 0.85)';
|
|
4769
|
+
X.lineWidth = 1.2*z;
|
|
4770
|
+
X.beginPath();
|
|
4771
|
+
X.moveTo(rimOuterScreen[0].x, rimOuterScreen[0].y);
|
|
4772
|
+
X.lineTo(rimOuterScreen[1].x, rimOuterScreen[1].y);
|
|
4773
|
+
X.lineTo(rimOuterScreen[2].x, rimOuterScreen[2].y);
|
|
4774
|
+
X.lineTo(rimOuterScreen[3].x, rimOuterScreen[3].y);
|
|
4775
|
+
X.closePath();
|
|
4776
|
+
X.stroke();
|
|
4777
|
+
X.restore();
|
|
4611
4778
|
// ── flower spots — deterministic small dots in 3 colors, scattered in the lawn only ──
|
|
4612
4779
|
X.save();
|
|
4613
4780
|
var flowerColors=['rgba(232,124,164,0.85)','rgba(248,220,96,0.88)','rgba(248,248,240,0.85)'];
|
|
@@ -6095,21 +6262,21 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
6095
6262
|
}
|
|
6096
6263
|
// Cars share the same elapsed-dt clock so a tab-switch doesn't
|
|
6097
6264
|
// catapult every car off the road at once. speed is world units
|
|
6098
|
-
// per second, dt is milliseconds.
|
|
6265
|
+
// per second, dt is milliseconds. cdt is the already-clamped dt.
|
|
6099
6266
|
var cdt = Math.min(64, dt);
|
|
6100
6267
|
for(var ci=0; ci<CARS.length; ci++){
|
|
6101
6268
|
var car = CARS[ci];
|
|
6102
6269
|
car.x += car.dir * car.speed * (cdt/1000);
|
|
6103
|
-
// Wrap the car around the road.
|
|
6104
|
-
// re-
|
|
6270
|
+
// Wrap the car around the road. When it leaves one end, it
|
|
6271
|
+
// re-enters the opposite end on the other lane so oncoming
|
|
6272
|
+
// traffic feels continuous.
|
|
6105
6273
|
if(car.dir > 0 && car.x > PARK_W + 0.5){
|
|
6106
6274
|
car.x = -0.5;
|
|
6107
|
-
|
|
6108
|
-
car.y = (car.y < -0.9) ? -0.78 : -1.02;
|
|
6275
|
+
car.y = (car.y < -0.9) ? -0.65 : -1.18;
|
|
6109
6276
|
car.dir = (car.y < -0.9) ? +1 : -1;
|
|
6110
6277
|
} else if(car.dir < 0 && car.x < -0.5){
|
|
6111
6278
|
car.x = PARK_W + 0.5;
|
|
6112
|
-
car.y = (car.y < -0.9) ? -0.
|
|
6279
|
+
car.y = (car.y < -0.9) ? -0.65 : -1.18;
|
|
6113
6280
|
car.dir = (car.y < -0.9) ? +1 : -1;
|
|
6114
6281
|
}
|
|
6115
6282
|
}
|
|
@@ -7025,7 +7192,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7025
7192
|
+ buildShellSection(schedId(n))
|
|
7026
7193
|
+ sect('🎭 character / voice','<div class="pk-note" id="pk-eff-note" style="margin-bottom:0.45rem;">loading effective config…</div><div class="rp-field"><label>Character preset</label><select class="rp-char" id="pk-eff-char" data-robot="'+esc(schedId(n))+'">'+effPresetOptions+'</select></div><div class="rp-field"><label>Voice stack</label><select class="rp-voice" id="pk-eff-voice" data-robot="'+esc(schedId(n))+'">'+effVoiceOptions+'</select></div><div class="rp-config-actions" style="margin-top:.45rem"><button class="rp-btn primary" id="pk-eff-save" data-robot="'+esc(schedId(n))+'">Apply to robot</button><button class="rp-btn" id="pk-edit-character">Edit character</button><button class="rp-btn" id="pk-edit-stack">Edit stack</button><button class="rp-btn" id="pk-new-stack">New stack</button></div>');
|
|
7027
7194
|
b.innerHTML += sect('Microphone setup suite','<div class="pk-mic-suite"><div class="pk-mic-health" id="pk-mic-health"><div class="pk-mic-check"><div class="k">Hardware lock</div><div class="v">USB mic hw:3,0</div></div><div class="pk-mic-check"><div class="k">PCM capture</div><div class="v">not tested</div></div><div class="pk-mic-check"><div class="k">Production track</div><div class="v">waiting</div></div></div><div class="rp-form" style="grid-template-columns:1fr 1fr;gap:.4rem"><div class="rp-field"><label>Ground-truth capture</label><select id="pk-mic-duration"><option value="2">2 seconds</option><option value="4" selected>4 seconds</option><option value="8">8 seconds</option></select></div><div class="rp-field"><label>Locked input</label><div class="pk-note" style="margin:.45rem 0 0">USB microphone (plughw:3,0)</div></div></div><div class="pk-ctl"><button class="rp-btn primary" id="pk-mic-groundtruth">Run raw mic capture</button><button class="rp-btn" id="pk-mic-roundtrip">Run speaker + mic test</button><button class="rp-btn" id="pk-mic-apply">Reapply production profile</button></div><div class="pk-note" id="pk-mic-test-state">Raw capture bypasses LiveKit and tests the exact ALSA path. Round-trip then verifies the physical speaker-to-mic path.</div><div id="pk-mic-test-result"></div></div>');
|
|
7028
|
-
b.innerHTML += sect('Motor registry + sequence canvas','<div class="rp-field"><label>Robot-local motor server</label><input id="pk-motor-url" value="http://127.0.0.1:8001" placeholder="http://127.0.0.1:8001"></div><div class="pk-motor-registry" id="pk-motor-registry"></div><button class="rp-btn" id="pk-motor-add" style="margin-top:.4rem">Add registered motor</button><div class="rp-form" style="grid-template-columns:1fr 1fr;margin-top:.7rem"><div class="rp-field"><label>Saved sequence</label><select id="pk-motor-sequence"></select></div><div class="rp-field"><label>Sequence name</label><input id="pk-motor-sequence-name" placeholder="Greeting motion"></div></div><div class="pk-motor-canvas" id="pk-motor-canvas"></div><div class="pk-ctl"><button class="rp-btn" id="pk-motor-step-add">Add timed node</button><button class="rp-btn primary" id="pk-motor-save">Save motor profile</button><button class="rp-btn" id="pk-motor-run">Run selected sequence</button></div><label class="pk-prodtoggle"><input type="checkbox" id="pk-motor-greeting"><span>Run this sequence automatically with the greeting pipeline</span></label><div class="pk-motor-status" id="pk-motor-status">Loading motor profile...</div>');
|
|
7195
|
+
b.innerHTML += sect('Motor registry + sequence canvas','<div class="rp-field"><label>Robot-local motor server</label><input id="pk-motor-url" value="http://127.0.0.1:8001" placeholder="http://127.0.0.1:8001"></div><div class="pk-motor-registry" id="pk-motor-registry"></div><button class="rp-btn" id="pk-motor-add" style="margin-top:.4rem">Add registered motor</button><div class="rp-form" style="grid-template-columns:1fr 1fr;margin-top:.7rem"><div class="rp-field"><label>Saved sequence</label><select id="pk-motor-sequence"></select></div><div class="rp-field"><label>Sequence name</label><input id="pk-motor-sequence-name" placeholder="Greeting motion"></div></div><div class="pk-motor-canvas" id="pk-motor-canvas"></div><div class="pk-ctl"><button class="rp-btn" id="pk-motor-step-add">Add timed node</button><button class="rp-btn primary" id="pk-motor-save">Save motor profile</button><button class="rp-btn" id="pk-motor-run">Run selected sequence</button><button class="rp-btn danger" id="pk-motor-test-all">Test all registered GPIO</button></div><label class="pk-prodtoggle"><input type="checkbox" id="pk-motor-greeting"><span>Run this sequence automatically with the greeting pipeline</span></label><div class="pk-note">Full GPIO test pulses registered relays only: 300 ms on, 200 ms pause, one at a time.</div><div class="pk-motor-status" id="pk-motor-status">Loading motor profile...</div>');
|
|
7029
7196
|
b.innerHTML += sect('Production camera + audio','<div class="pk-note" id="pk-device-search" style="margin-bottom:.55rem">Hardware locked for launch. Camera, microphone, and speaker cannot drift between restarts.</div><div class="rp-field"><label>Camera input</label><select id="pk-video-device" disabled></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input" disabled></select></div><div class="rp-field"><label>Speaker output</label><select id="pk-audio-output" disabled></select></div><button class="rp-btn primary" id="pk-device-test-speaker" style="margin-top:0.35rem">Test USB speaker now</button><div class="rp-field"><label>Preset greeting phrases <span class="pk-note">one per line, cached per voice</span></label><textarea id="pk-greeting-phrases" rows="3" placeholder="Hi, I am Jaguar! / Welcome to RoboPark."></textarea></div><button class="rp-btn" id="pk-device-save" style="margin-top:0.35rem">Save greetings + enforce hardware</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
|
|
7030
7197
|
// Put live hardware selection beside the camera and audio tests,
|
|
7031
7198
|
// ahead of persona and service administration in the Park drawer.
|
|
@@ -7055,6 +7222,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7055
7222
|
var motorStep=$('pk-motor-step-add');if(motorStep)motorStep.addEventListener('click',function(){motorEditorAddStep();});
|
|
7056
7223
|
var motorSave=$('pk-motor-save');if(motorSave)motorSave.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveMotorProfile(n);});
|
|
7057
7224
|
var motorRun=$('pk-motor-run');if(motorRun)motorRun.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMotorSequence(n);});
|
|
7225
|
+
var motorTestAll=$('pk-motor-test-all');if(motorTestAll)motorTestAll.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}testAllMotorRelays(n);});
|
|
7058
7226
|
var motorSeq=$('pk-motor-sequence');if(motorSeq)motorSeq.addEventListener('change',function(){selectMotorSequence(motorSeq.value);});
|
|
7059
7227
|
var motorRegistry=$('pk-motor-registry');if(motorRegistry)motorRegistry.addEventListener('click',function(ev){var button=ev.target.closest('[data-motor-action]');if(!button)return;var index=Number(button.dataset.index);if(button.dataset.motorAction==='remove'){motorProfile.registry.splice(index,1);renderMotorEditor();}else if(button.dataset.motorAction==='test'){testMotorRelay(n,index);}});
|
|
7060
7228
|
var motorCanvas=$('pk-motor-canvas');if(motorCanvas)motorCanvas.addEventListener('click',function(ev){var button=ev.target.closest('[data-step-remove]');if(button){captureMotorEditor();motorEditorSequence().steps.splice(Number(button.dataset.stepRemove),1);renderMotorEditor();}});
|
|
@@ -7165,6 +7333,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
7165
7333
|
captureMotorEditor();var motor=motorProfile.registry[index];if(!motor)return;var robotId=schedId(n);motorStatus('Saving profile before safe 300 ms '+motor.name+' test...');
|
|
7166
7334
|
saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/'+encodeURIComponent(motor.id)+'/test?duration_ms=300',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-test');}).then(function(result){motorStatus(result.ok?motor.name+' relay test completed.':'Relay test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Relay test failed: '+error.message,'bad');});
|
|
7167
7335
|
}
|
|
7336
|
+
function testAllMotorRelays(n){
|
|
7337
|
+
captureMotorEditor();if(!motorProfile.registry.length){motorStatus('Register at least one relay before running the GPIO test.','bad');return;}
|
|
7338
|
+
if(!window.confirm('Pulse all '+motorProfile.registry.length+' registered relays one at a time? Ensure the motor area is clear.'))return;
|
|
7339
|
+
var robotId=schedId(n);motorStatus('Saving profile and testing '+motorProfile.registry.length+' registered GPIO relay(s), one at a time...');
|
|
7340
|
+
saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/test-all?duration_ms=300&pause_ms=200',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','gpio-test-all');}).then(function(result){motorStatus(result.ok?'Full registered GPIO test completed: '+(result.completed_steps||[]).length+' relay(s).':'GPIO test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('GPIO test failed: '+error.message,'bad');});
|
|
7341
|
+
}
|
|
7168
7342
|
|
|
7169
7343
|
var pipelineHistoryPage=1;
|
|
7170
7344
|
var PIPELINE_LABELS={
|
|
@@ -139,6 +139,12 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
139
139
|
// from these USB product labels on every inventory refresh.
|
|
140
140
|
ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
|
|
141
141
|
ROBOPARK_AUDIO_OUTPUT_MATCH: 'USB Audio Device: -',
|
|
142
|
+
// Launch hardware has no acoustic echo cancellation. Suppress mic PCM
|
|
143
|
+
// only while audible robot speech is playing, then hold briefly for
|
|
144
|
+
// room echo decay so VAD cannot interrupt the robot with its own voice.
|
|
145
|
+
ROBOPARK_HALF_DUPLEX: 'true',
|
|
146
|
+
ROBOPARK_ECHO_TAIL_MS: '700',
|
|
147
|
+
ROBOPARK_ECHO_GATE_PEAK: '96',
|
|
142
148
|
// Inventory changes should reach the Control Center promptly.
|
|
143
149
|
HEARTBEAT_INTERVAL: '5',
|
|
144
150
|
},
|
|
@@ -179,12 +179,14 @@ Required result: `playback_started ok`.
|
|
|
179
179
|
measurement, and fallback resampling with internal PCM16 helpers; production
|
|
180
180
|
capture must not import `audioop`.
|
|
181
181
|
|
|
182
|
-
## Motor and Relay Contract (2.8.
|
|
182
|
+
## Motor and Relay Contract (2.8.99+)
|
|
183
183
|
|
|
184
184
|
- Each robot persists its own motor registry, timed sequences, local motor API
|
|
185
185
|
URL, and optional greeting sequence in the scheduler database.
|
|
186
186
|
- The Park robot drawer is the control surface for registry edits, BCM relay
|
|
187
187
|
tests, sequence authoring, manual runs, and greeting assignment.
|
|
188
|
+
- The full GPIO diagnostic requires operator confirmation and pulses every
|
|
189
|
+
registered relay sequentially. It never scans or energizes unregistered pins.
|
|
188
190
|
- Greeting motion queues only after the robot reports `playback_started`, so
|
|
189
191
|
movement aligns with audible TTS instead of session allocation.
|
|
190
192
|
- Voice agents may invoke only saved sequences for their active LiveKit room
|
|
@@ -197,3 +199,16 @@ Required result: `playback_started ok`.
|
|
|
197
199
|
rejects duplicate assignments, limits pulses to 10 seconds, serializes all
|
|
198
200
|
actuation, and de-energizes every registered relay on errors and shutdown.
|
|
199
201
|
- A missing or busy GPIO driver is a failed test, never a simulated success.
|
|
202
|
+
|
|
203
|
+
## Robot Speaker Echo Contract (2.8.100+)
|
|
204
|
+
|
|
205
|
+
- The launch USB speaker/microphone stack has no hardware acoustic echo
|
|
206
|
+
cancellation, so production uses robot-side half-duplex echo suppression.
|
|
207
|
+
- While outbound TTS contains audible PCM, the Pi publishes timing-correct
|
|
208
|
+
silence instead of amplified microphone PCM. The microphone track remains
|
|
209
|
+
connected and resumes after a 700 ms acoustic decay tail.
|
|
210
|
+
- Silent frames on the persistent TTS track do not hold the gate open. Output
|
|
211
|
+
peak detection releases the visitor microphone between responses.
|
|
212
|
+
- `ROBOPARK_HALF_DUPLEX`, `ROBOPARK_ECHO_TAIL_MS`, and
|
|
213
|
+
`ROBOPARK_ECHO_GATE_PEAK` are explicit runtime controls. Disable half-duplex
|
|
214
|
+
only after deploying real acoustic echo cancellation.
|
package/package.json
CHANGED
|
@@ -4030,6 +4030,26 @@ async def test_robot_motor(robot_id: str, motor_id: str, duration_ms: int = 300)
|
|
|
4030
4030
|
"steps": [{"motor_id": motor_id, "delay_ms": 0, "duration_ms": duration_ms}],
|
|
4031
4031
|
})
|
|
4032
4032
|
return {"queued": True, "request_id": request_id, "device_id": model.id}
|
|
4033
|
+
|
|
4034
|
+
@app.post("/api/robots/{robot_id}/motors/test-all")
|
|
4035
|
+
async def test_all_robot_motors(robot_id: str, duration_ms: int = 300, pause_ms: int = 200):
|
|
4036
|
+
device = await _resolve_device_by_id_or_name(robot_id)
|
|
4037
|
+
model = _device_row_to_model(device)
|
|
4038
|
+
if not model.motor_registry or not model.motor_server_url:
|
|
4039
|
+
raise HTTPException(409, "Robot motor registry or motor server is not configured")
|
|
4040
|
+
duration_ms = max(50, min(1000, int(duration_ms)))
|
|
4041
|
+
pause_ms = max(0, min(2000, int(pause_ms)))
|
|
4042
|
+
request_id = await _queue_shell_request_async(model.id, "motor_sequence", "motor_server", {
|
|
4043
|
+
"motor_server_url": model.motor_server_url,
|
|
4044
|
+
"sequence_id": "registered-gpio-test",
|
|
4045
|
+
"registry": model.motor_registry,
|
|
4046
|
+
"steps": [],
|
|
4047
|
+
"test_all": True,
|
|
4048
|
+
"duration_ms": duration_ms,
|
|
4049
|
+
"pause_ms": pause_ms,
|
|
4050
|
+
})
|
|
4051
|
+
return {"queued": True, "request_id": request_id, "device_id": model.id,
|
|
4052
|
+
"motor_count": len(model.motor_registry)}
|
|
4033
4053
|
|
|
4034
4054
|
@app.post("/api/devices/{device_id}/request-session")
|
|
4035
4055
|
async def device_request_session(device_id: str,
|
|
@@ -1284,6 +1284,19 @@ class LiveKitPublisher:
|
|
|
1284
1284
|
self._mic_capture: Optional["AudioCapture"] = None
|
|
1285
1285
|
self._mic_streaming = asyncio.Event()
|
|
1286
1286
|
self._mic_error: Optional[str] = None
|
|
1287
|
+
# The launch hardware has no acoustic echo cancellation. Publishing
|
|
1288
|
+
# the amplified USB microphone while the robot speaker plays TTS makes
|
|
1289
|
+
# the voice worker hear itself and trigger barge-in, truncating or
|
|
1290
|
+
# chopping its own response. Keep the track alive with silence while
|
|
1291
|
+
# playback is active, plus a short room-echo decay tail.
|
|
1292
|
+
self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
|
|
1293
|
+
"1", "true", "yes", "on",
|
|
1294
|
+
)
|
|
1295
|
+
self._speaker_playback_active = threading.Event()
|
|
1296
|
+
self._speaker_gate_until = 0.0
|
|
1297
|
+
self._speaker_echo_tail = max(
|
|
1298
|
+
0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "700")) / 1000.0, 2.0)
|
|
1299
|
+
)
|
|
1287
1300
|
# Motion sampling state belongs to the publisher instance. Keeping it
|
|
1288
1301
|
# initialized here prevents shutdown/reopen paths from raising while
|
|
1289
1302
|
# the camera is being handed between preview and voice sessions.
|
|
@@ -1524,12 +1537,41 @@ class LiveKitPublisher:
|
|
|
1524
1537
|
stream_failed = threading.Event()
|
|
1525
1538
|
playback_reported = threading.Event()
|
|
1526
1539
|
event_loop = asyncio.get_running_loop()
|
|
1540
|
+
echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
|
|
1541
|
+
|
|
1542
|
+
def _outbound_peak(chunk: bytes) -> int:
|
|
1543
|
+
if len(chunk) < 2:
|
|
1544
|
+
return 0
|
|
1545
|
+
try:
|
|
1546
|
+
samples = memoryview(chunk).cast("h")
|
|
1547
|
+
# Stereo duplication means sampling every eighth value is
|
|
1548
|
+
# sufficient and keeps the writer thread lightweight.
|
|
1549
|
+
return max((abs(int(value)) for value in samples[::8]), default=0)
|
|
1550
|
+
except (TypeError, ValueError):
|
|
1551
|
+
return 0
|
|
1552
|
+
|
|
1553
|
+
def _open_echo_gate() -> None:
|
|
1554
|
+
if not self._half_duplex:
|
|
1555
|
+
return
|
|
1556
|
+
self._speaker_playback_active.set()
|
|
1557
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1558
|
+
|
|
1559
|
+
def _extend_echo_gate() -> None:
|
|
1560
|
+
if self._half_duplex:
|
|
1561
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1527
1562
|
|
|
1528
|
-
def _safe_write(chunk: bytes) -> bool:
|
|
1529
|
-
if stream_failed.is_set():
|
|
1530
|
-
return False
|
|
1563
|
+
def _safe_write(chunk: bytes) -> bool:
|
|
1564
|
+
if stream_failed.is_set():
|
|
1565
|
+
return False
|
|
1531
1566
|
try:
|
|
1567
|
+
audible = _outbound_peak(chunk) >= echo_gate_peak
|
|
1568
|
+
if audible:
|
|
1569
|
+
_open_echo_gate()
|
|
1570
|
+
elif time.monotonic() >= self._speaker_gate_until:
|
|
1571
|
+
self._speaker_playback_active.clear()
|
|
1532
1572
|
out.write(chunk)
|
|
1573
|
+
if audible:
|
|
1574
|
+
_extend_echo_gate()
|
|
1533
1575
|
if not playback_reported.is_set():
|
|
1534
1576
|
playback_reported.set()
|
|
1535
1577
|
asyncio.run_coroutine_threadsafe(
|
|
@@ -1661,6 +1703,9 @@ class LiveKitPublisher:
|
|
|
1661
1703
|
pa.terminate()
|
|
1662
1704
|
if speaker_guard is not None:
|
|
1663
1705
|
speaker_guard.release()
|
|
1706
|
+
if self._half_duplex:
|
|
1707
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1708
|
+
self._speaker_playback_active.clear()
|
|
1664
1709
|
|
|
1665
1710
|
async def stop(self) -> None:
|
|
1666
1711
|
self._stop_event.set()
|
|
@@ -1783,8 +1828,9 @@ class LiveKitPublisher:
|
|
|
1783
1828
|
_diag_count = 0
|
|
1784
1829
|
_diag_last_log = time.monotonic()
|
|
1785
1830
|
_diag_read_ms = 0.0
|
|
1786
|
-
_diag_publish_ms = 0.0
|
|
1787
|
-
|
|
1831
|
+
_diag_publish_ms = 0.0
|
|
1832
|
+
_echo_gate_was_active = False
|
|
1833
|
+
batch_interval = BATCH_MS / 1000
|
|
1788
1834
|
while not self._stop_event.is_set():
|
|
1789
1835
|
_iter_start = time.monotonic()
|
|
1790
1836
|
_t0 = time.monotonic()
|
|
@@ -1792,10 +1838,27 @@ class LiveKitPublisher:
|
|
|
1792
1838
|
_t1 = time.monotonic()
|
|
1793
1839
|
if batch is not None:
|
|
1794
1840
|
data = bytes(batch.data)
|
|
1795
|
-
_pub_start = time.monotonic()
|
|
1796
|
-
for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
|
|
1841
|
+
_pub_start = time.monotonic()
|
|
1842
|
+
for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
|
|
1797
1843
|
chunk = data[off:off + bytes_per_frame]
|
|
1798
|
-
|
|
1844
|
+
echo_gate_active = self._half_duplex and (
|
|
1845
|
+
self._speaker_playback_active.is_set()
|
|
1846
|
+
or time.monotonic() < self._speaker_gate_until
|
|
1847
|
+
)
|
|
1848
|
+
if echo_gate_active:
|
|
1849
|
+
# Preserve 20 ms frame cadence; only suppress content.
|
|
1850
|
+
# Stopping publication would create gaps and destabilize
|
|
1851
|
+
# VAD/endpointing when listening resumes.
|
|
1852
|
+
chunk = b"\x00" * len(chunk)
|
|
1853
|
+
p = 0
|
|
1854
|
+
else:
|
|
1855
|
+
chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
|
|
1856
|
+
if echo_gate_active != _echo_gate_was_active:
|
|
1857
|
+
logger.info(
|
|
1858
|
+
"audio_loop: speaker echo gate %s",
|
|
1859
|
+
"active" if echo_gate_active else "released",
|
|
1860
|
+
)
|
|
1861
|
+
_echo_gate_was_active = echo_gate_active
|
|
1799
1862
|
frame = self.rtc.AudioFrame(
|
|
1800
1863
|
data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
|
|
1801
1864
|
)
|
|
@@ -453,6 +453,27 @@ def _run_motor_sequence(params: dict) -> dict:
|
|
|
453
453
|
response = (client.put(f"{base}/update-motor/{motor_id}", json=body)
|
|
454
454
|
if motor_id in existing_names else client.post(f"{base}/add-motor", json=body))
|
|
455
455
|
response.raise_for_status()
|
|
456
|
+
if params.get("test_all"):
|
|
457
|
+
duration_ms = max(50, min(1000, int(params.get("duration_ms", 300))))
|
|
458
|
+
pause_ms = max(0, min(2000, int(params.get("pause_ms", 200))))
|
|
459
|
+
response = client.post(f"{base}/test", json={
|
|
460
|
+
"seconds_on": duration_ms / 1000.0,
|
|
461
|
+
"seconds_pause": pause_ms / 1000.0,
|
|
462
|
+
"pins": [int(motor["gpio"]) for motor in registry.values()],
|
|
463
|
+
})
|
|
464
|
+
response.raise_for_status()
|
|
465
|
+
deadline = time.monotonic() + len(registry) * ((duration_ms + pause_ms) / 1000.0) + 3.0
|
|
466
|
+
while time.monotonic() < deadline:
|
|
467
|
+
status = client.get(f"{base}/status").json()
|
|
468
|
+
if status.get("status") == "idle":
|
|
469
|
+
if status.get("error"):
|
|
470
|
+
raise RuntimeError(f"registered GPIO test failed: {status['error']}")
|
|
471
|
+
completed = [{"motor_id": motor_id, "gpio": int(motor["gpio"])}
|
|
472
|
+
for motor_id, motor in registry.items()]
|
|
473
|
+
break
|
|
474
|
+
time.sleep(0.05)
|
|
475
|
+
else:
|
|
476
|
+
raise TimeoutError("registered GPIO test did not return to idle")
|
|
456
477
|
for index, step in enumerate(steps):
|
|
457
478
|
motor_id = str(step.get("motor_id"))
|
|
458
479
|
motor = registry.get(motor_id)
|