diff --git a/web/calibrate/Light9Calibrate.ts b/web/calibrate/Light9Calibrate.ts
new file mode 100644
--- /dev/null
+++ b/web/calibrate/Light9Calibrate.ts
@@ -0,0 +1,128 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, query, state } from "lit/decorators.js";
+import { CollectorClient } from "../collector/CollectorClient";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Light9Camera } from "./Light9Camera";
+import { XyPlot } from "./XyPlot";
+export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
+export { Light9Camera } from "./Light9Camera";
+export { XyPlot } from "./XyPlot";
+debug.enable("*");
+const log = debug("calibrate");
+
+async function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+@customElement("light9-calibrate")
+export class Light9Calibrate extends LitElement {
+ graph!: SyncedGraph;
+ static styles = [
+ css`
+ button {
+ min-height: 3em;
+ min-width: 10em;
+ }
+ `,
+ ];
+ collector: CollectorClient = new CollectorClient("calibrate");
+ @query("light9-camera", true) cam?: Light9Camera;
+ @query("xy-plot", true) plot?: XyPlot;
+ @state() device: string;
+ constructor() {
+ super();
+ this.device = "http://light9.bigasterisk.com/theater/vet/device/parR3";
+ getTopGraph().then((g) => {
+ this.graph = g;
+ });
+ }
+
+ render() {
+ return html`
+
Calibrate
+
+
+ Device to calibrate: [ ${this.device} ]
+
+
+
+
+
+
+
+
+
+ r/g/b/r*g*b lines ,x=send y=seen `;
+ }
+
+ async withButtonSpinner(ev: MouseEvent, fn: () => Promise) {
+ const btn = ev.target as HTMLButtonElement;
+ try {
+ btn.disabled = true;
+ await fn();
+ } finally {
+ btn.disabled = false;
+ }
+ }
+ async setToFull(ev: MouseEvent) {
+ await this.withButtonSpinner(ev, async () => {
+ this.collector.updateSettings([
+ /// device,attr,value
+ [this.device, "http://light9.bigasterisk.com/color", "#ffffff"],
+ [this.device, "http://light9.bigasterisk.com/white", 1],
+ ]);
+ });
+ }
+
+ async findSafeExposure(ev: MouseEvent) {
+ await this.withButtonSpinner(ev, async () => {
+
+ const gatherSample = async (expo: number) => {
+ await this.cam?.set("exposureTime", expo);
+ const settleUntil = Date.now() + 1000;
+ let miny = this.cam?.saturatedPixelCount!;
+ while (Date.now() < settleUntil) {
+ await sleep(50);
+ miny = Math.min(miny, this.cam?.saturatedPixelCount!);
+ }
+ this.plot!.insertPoint(expo, miny);
+ };
+
+ // todo: drive around without big skips, gradually slower, looking for the max workable expo
+ let fixedSteps = 8;
+ const expoMin = 1;
+ const expoMax = 200;
+ let expo = 0;
+ const data=this.plot!.data;
+ while (data.length < 20) {
+ if (data.length < fixedSteps + 1) {
+ expo = expoMin + ((expoMax - expoMin) / fixedSteps) * data.length;
+ } else {
+ let x2 = data.findIndex(([_, y]) => y > 2);
+ if (x2 < 1) x2 = 1;
+ const x1 = x2 - 1;
+ log(JSON.stringify([x1, data[x1], x2, data[x2]]));
+ expo = (data[x1][0] + data[x2][0]) / 2;
+ log(data);
+ }
+ await gatherSample(expo);
+ }
+ });
+ }
+ async setToZero(ev: MouseEvent) {
+ await this.withButtonSpinner(ev, async () => {
+ this.collector.updateSettings([
+ [this.device, "http://light9.bigasterisk.com/color", "#000000"],
+ [this.device, "http://light9.bigasterisk.com/white", 0],
+ ]);
+ });
+ }
+ async markTare(ev: MouseEvent) {
+ await this.withButtonSpinner(ev, async () => {});
+ }
+ async calibrateLoop(ev: MouseEvent) {
+ await this.withButtonSpinner(ev, async () => {});
+ }
+}