Files
NODEDC_MISSION_CORE/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts
T
DCCONSTRUCTIONS b53d6d5a45 feat(perception): integrate calibrated operator pipeline
Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
2026-07-23 00:23:28 +03:00

785 lines
31 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { BackendStatus, ViewerSettings } from "@mission-core/plugin-sdk";
import {
ApiError,
xgridsK1Api,
openEventSocket,
type ConnectRequest,
type EventSocketStatus,
type OpenApplicationControlSessionRequest,
type OperatorPresenceConfirmation,
type PrepareAcquisitionRequest,
type ReplayRequest,
type XgridsOperation,
type XgridsApplicationControlPhase,
type XgridsK1State,
} from "./api";
import {
controlSessionEntryPlan,
isTerminalAcquisitionState,
liveStartPlan,
operationByIdempotencyKey,
operationNeedsReconciliation,
isSoftwareCommandedAcquisition,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import {
awaitWhileIntentCurrent,
OperatorIntentGeneration,
} from "./operatorIntentGeneration";
import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
| "connect"
| "control"
| "live"
| "replay"
| "stop"
| "abort"
| "camera"
| "viewer";
export interface CanonicalLiveStartRequest {
control: OpenApplicationControlSessionRequest;
acquisition: PrepareAcquisitionRequest;
physicalAcceptance: OperatorPresenceConfirmation;
}
const CONTROL_STATE_READ_INTERVAL_MS = 250;
function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
return state.application_control_session?.state ?? "idle";
}
function controlFailure(state: XgridsK1State): ApiError {
const failure = state.application_control_session?.failure;
const localizedDetail = localizeRuntimeMessage(failure?.message);
const reasonLabels: Record<string, string> = {
application_authority_unavailable:
"Локальный допуск управления устройством недоступен; команды не отправлялись.",
mqtt_connect_call_failed:
"Управляющее MQTT-соединение со сканером не открылось.",
mqtt_connect_rejected:
"Сканер отклонил управляющее MQTT-соединение.",
mqtt_connection_timeout:
"Подключение или подписки MQTT не подтвердились вовремя.",
mqtt_subscription_failed:
"Сканер не подтвердил канонические MQTT-подписки.",
mqtt_response_timeout:
"Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.",
response_identity_decode_failed:
"Ответ сканера не удалось безопасно разобрать и привязать к операции.",
modeling_response_decode_failed:
"Ответ START/STOP не удалось безопасно разобрать.",
duplicate_application_response:
"Сканер прислал повторный ответ на уже завершённую операцию.",
unexpected_response_identity:
"Получен ответ неизвестной операции; диалог остановлен.",
response_identity_mismatch:
"Идентичность ответа не совпала с ожидаемой операцией.",
response_session_mismatch:
"Session ответа не совпал с точной подготовительной операцией.",
response_device_identity_mismatch:
"Ответ относится не к тому экземпляру устройства.",
response_authority_mismatch:
"Ответ не совпал с локальным допуском приложения.",
response_rejected:
"Сканер отклонил подготовительную операцию.",
compatibility_profile_mismatch:
"Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.",
scan_initialization_timeout:
"После подтверждённого START сканер не завершил инициализацию в безопасный срок.",
operation_reuse_forbidden:
"Повтор уже использованной операции заблокирован.",
};
const reasonDetail = failure?.reason_code
? reasonLabels[failure.reason_code]
: undefined;
const stageLabels: Record<string, string> = {
connecting: "подключение и первичный диалог",
connection: "первичный диалог",
"connection-ready": "подключение подтверждено",
"workspace-requested": "вход в рабочую область",
"workspace-ready": "рабочая область готова",
"project-requested": "подготовка проекта",
"project-ready": "проект готов",
"start-requested": "подтверждение запуска",
"start-attempted": "команда запуска",
initializing: "калибровка оборудования",
scanning: "сканирование",
"stop-requested": "подтверждение остановки",
"stop-attempted": "команда остановки",
stopping: "остановка",
};
const stageCode = failure?.dialogue_stage || failure?.failed_phase;
const stage = stageCode
? stageLabels[stageCode] ?? "неопознанный этап канонического диалога"
: "этап не зафиксирован";
const commandStatus = failure?.modeling_command_attempted === true
? "Команда START или STOP могла быть отправлена; автоматический повтор запрещён."
: failure?.modeling_command_attempted === false
? "Команды START и STOP не отправлялись."
: "Факт отправки START или STOP диагностически не подтверждён; повтор запрещён.";
const retryStatus = failure?.safe_to_retry
? "Новая попытка возможна только отдельным нажатием оператора."
: "Повтор заблокирован до ручной проверки состояния.";
const exchanges = typeof failure?.publish_attempts === "number"
? `MQTT-публикаций до остановки: ${failure.publish_attempts}.`
: "Количество MQTT-публикаций не зафиксировано.";
const failedProtocolEvidence =
failure?.compatibility_failure ?? failure?.correlation_failure;
const failedOperation = failedProtocolEvidence?.operation_key
? `Шаг протокола: ${failedProtocolEvidence.operation_key}.`
: "";
const diagnostics = failure?.diagnostic_evidence_unavailable?.length
? "Часть диагностических доказательств недоступна; результат считается неизвестным."
: "";
return new ApiError(
`${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
);
}
async function waitForControlPhase(
expected: XgridsApplicationControlPhase,
acceptState: (state: XgridsK1State) => void,
assertOperatorIntentCurrent: () => void,
): Promise<XgridsK1State> {
for (;;) {
assertOperatorIntentCurrent();
const nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.getState(),
);
acceptState(nextState);
const phase = controlPhase(nextState);
if (phase === expected) return nextState;
if (phase === "failed") throw controlFailure(nextState);
if (["idle", "closed", "completed"].includes(phase)) {
throw new ApiError(
`Управляющая сессия K1 завершилась до ожидаемого этапа «${expected}».`,
);
}
// This cadence only reads local server state. It never schedules, retries,
// or times a K1 command; every next write remains gated by device response.
await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => new Promise<void>((resolve) => {
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
}),
);
}
}
function messageFor(error: unknown): string {
if (error instanceof ApiError) {
// Domain errors are already written for the operator. Only HTTP details
// originate at the backend and need vendor/runtime normalization.
const message = error.status
? localizeRuntimeMessage(error.message) ?? error.message
: error.message;
return error.status
? `${message} (HTTP ${error.status})`
: message;
}
return "Запрос к локальному сервису устройства завершился ошибкой.";
}
function networkProvisionFailureMessage(
operation: XgridsOperation | null | undefined,
): string | null {
if (!operation || operation.status !== "failed") return null;
const code = operation.error?.code;
if (typeof code !== "string") return null;
const messages: Record<string, string> = {
"network-not-found":
"Точка доступа выбранного K1 не найдена. Команда включения точки не повторялась; проверьте питание и состояние K1.",
"credential-entry-cancelled":
"Первичная регистрация пароля K1 отменена. Получите пароль сохранённой сети этого K1 на авторизованном устройстве и повторите подключение отдельным действием.",
"credential-invalid":
"Пароль точки доступа K1 имеет недопустимую длину. Получите сохранённый пароль этого K1 в LixelGO/iPhone и повторите подключение.",
"host-wifi-operation-timeout":
"Первичное системное подключение к K1 не было завершено вовремя. BLE-команда автоматически не повторялась; получите пароль сохранённой сети этого K1 и запустите подключение заново.",
"profile-ssid-mismatch":
"Сохранённый профиль относится к другому K1. Подключение остановлено без повторной команды сканеру.",
"corewlan-error":
"macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.",
"wifi-interface-unavailable":
"Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.",
"unsupported-platform":
"Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.",
};
return messages[code] ?? null;
}
function measuredLatency(state: XgridsK1State | null): number | null {
if (state?.source_mode !== "live") return null;
const metrics = state?.metrics;
if (!metrics) return null;
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
const segments = [
metrics.mqtt_to_decode_ms,
metrics.publish_ms,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
export function useXgridsK1Runtime(enabled: boolean) {
const [state, setState] = useState<XgridsK1State | null>(null);
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const operatorIntents = useRef(new OperatorIntentGeneration());
const actionSequence = useRef(0);
const actionInFlight = useRef<{
runtimeGeneration: number;
actionSequence: number;
} | null>(null);
const acceptState = useCallback((nextState: XgridsK1State) => {
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
setBackendStatus("online");
}, []);
const refresh = useCallback(async (reportErrors = true) => {
const runtimeToken = operatorIntents.current.captureRuntime();
if (!enabled || !runtimeToken) return;
const [healthResult, stateResult] = await Promise.allSettled([
xgridsK1Api.getHealth(),
xgridsK1Api.getState(),
]);
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return;
if (stateResult.status === "fulfilled") {
acceptState(stateResult.value);
if (reportErrors) setError(null);
}
if (healthResult.status === "fulfilled") {
const health = healthResult.value;
const healthy = health.ok !== false && health.status !== "error";
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
} else if (stateResult.status === "rejected") {
setBackendStatus("offline");
}
if (stateResult.status === "rejected" && reportErrors) {
setError(messageFor(stateResult.reason));
}
}, [acceptState, enabled]);
const run = useCallback(
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
const runtimeToken = operatorIntents.current.captureRuntime();
if (!enabled || !runtimeToken) return false;
if (
actionInFlight.current?.runtimeGeneration
=== runtimeToken.runtimeGeneration
) return false;
actionSequence.current += 1;
const actionToken = {
runtimeGeneration: runtimeToken.runtimeGeneration,
actionSequence: actionSequence.current,
};
actionInFlight.current = actionToken;
setPendingAction(action);
setError(null);
try {
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
const nextState = await operation();
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
acceptState(nextState);
return true;
} catch (operationError) {
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
setError(messageFor(operationError));
if (
operationError instanceof ApiError
&& operationError.transportUnavailable
) {
setBackendStatus("offline");
}
}
return false;
} finally {
if (
actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration
&& actionInFlight.current.actionSequence === actionToken.actionSequence
) {
actionInFlight.current = null;
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
setPendingAction(null);
}
}
}
},
[acceptState, enabled],
);
const scan = useCallback(
() => run("scan", () => xgridsK1Api.scanBle({ duration_seconds: 6 })),
[run],
);
const connect = useCallback(
(request: ConnectRequest) =>
run("connect", async () => {
const previous = operationByIdempotencyKey(
state,
"network.provision",
request.idempotency_key,
);
if (previous?.status === "succeeded" && state) return state;
if (operationNeedsReconciliation(previous)) {
throw new ApiError(
"Предыдущая запись настроек завершилась с неопределённым результатом. Автоматический повтор заблокирован; измените параметры только после проверки устройства.",
);
}
let nextState: XgridsK1State;
try {
nextState = await xgridsK1Api.connect(request);
} catch (connectError) {
// A failed network action is terminal and persisted by the backend.
// Re-read state only; never replay the device command. This replaces
// an opaque HTTP 502 banner with the exact host-association outcome.
let failedState: XgridsK1State;
try {
failedState = await xgridsK1Api.getState();
} catch {
throw connectError;
}
acceptState(failedState);
const failedOperation = operationByIdempotencyKey(
failedState,
"network.provision",
request.idempotency_key,
);
const failureMessage = networkProvisionFailureMessage(failedOperation);
if (failureMessage) throw new ApiError(failureMessage);
throw connectError;
}
const operation = operationByIdempotencyKey(
nextState,
"network.provision",
request.idempotency_key,
);
if (operationNeedsReconciliation(operation)) {
throw new ApiError(
"Результат записи настроек требует ручной проверки. Повторная аппаратная запись не выполнена.",
);
}
if (operation && operation.status !== "succeeded") {
throw new ApiError("Запись настроек ещё выполняется; дождитесь обновления состояния.");
}
return nextState;
}),
[acceptState, run, state],
);
const openApplicationControlSession = useCallback(
(request: OpenApplicationControlSessionRequest) =>
run("control", () => xgridsK1Api.openApplicationControlSession(request)),
[run],
);
const enterApplicationWorkspace = useCallback(
() =>
run("control", () =>
xgridsK1Api.enterApplicationWorkspace({ operator_confirmed: true }),
),
[run],
);
const closeApplicationControlSession = useCallback(
() => run("control", () => xgridsK1Api.closeApplicationControlSession()),
[run],
);
const startCanonicalAcquisition = useCallback(
(request: CanonicalLiveStartRequest) =>
run("live", async () => {
const intentToken = operatorIntents.current.beginOperatorIntent();
if (!intentToken) {
throw new ApiError(
"Экран управления закрыт; дальнейшие команды канонического диалога не отправлялись.",
);
}
const assertOperatorIntentCurrent = () => {
if (!operatorIntents.current.isOperatorIntentCurrent(intentToken)) {
throw new ApiError(
"Операторское действие завершено или заменено; дальнейшие команды канонического диалога не отправлялись.",
);
}
};
let nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.getState(),
);
let openedControlSession = false;
acceptState(nextState);
const plan = liveStartPlan(nextState);
if (plan === "blocked") {
throw new ApiError("Сначала завершите текущий приём или повтор записи.");
}
if (plan === "already-running") return nextState;
for (;;) {
assertOperatorIntentCurrent();
const phase = controlPhase(nextState);
const acquisition = nextState.acquisition;
const entryPlan = controlSessionEntryPlan(
phase,
openedControlSession,
nextState.application_control_session?.can_open === true,
);
if (entryPlan === "failed") {
// A failed dialogue always ends this operator intent. Even when
// backend reconciliation says a fresh attempt may be safe, that
// attempt requires another explicit click.
throw controlFailure(nextState);
}
if (entryPlan === "duplicate-open") {
throw new ApiError(
"Управляющая сессия завершилась сразу после открытия. Автоматический повтор заблокирован; проверьте состояние и повторите только отдельным нажатием.",
);
}
if (entryPlan === "open") {
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
throw new ApiError(
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
);
}
openedControlSession = true;
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.openApplicationControlSession(request.control),
);
acceptState(nextState);
continue;
}
if (phase === "connecting") {
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"connection-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "connection-ready") {
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.enterApplicationWorkspace({
operator_confirmed: true,
}),
);
acceptState(nextState);
continue;
}
if (phase === "workspace-requested") {
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"workspace-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "workspace-ready") {
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.prepareAcquisition(request.acquisition),
);
acceptState(nextState);
continue;
}
if (acquisition.state !== "prepared" || acquisition.control_mode !== "plugin-commanded") {
throw new ApiError(
"Текущая подготовка не принадлежит канонической control-сессии K1.",
);
}
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"project-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "project-requested") {
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"project-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "project-ready") {
if (!acquisition || acquisition.state !== "prepared") {
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
}
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: request.physicalAcceptance,
}),
);
acceptState(nextState);
return nextState;
}
if (["start-requested", "initializing", "scanning"].includes(phase)) {
return nextState;
}
throw new ApiError(`Запуск K1 недоступен из состояния «${phase}».`);
}
}),
[acceptState, run],
);
const prepareAcquisition = useCallback(
(request: PrepareAcquisitionRequest) =>
run("live", async () => {
const plan = liveStartPlan(state);
if (plan === "blocked") {
throw new ApiError(
"Сначала завершите текущий приём или повтор записи.",
);
}
if (plan === "already-running" && state) return state;
return (
plan === "resume-prepared" && state
? state
: await xgridsK1Api.prepareAcquisition(request)
);
}),
[run, state],
);
const startPreparedAcquisition = useCallback(
(physicalAcceptance: OperatorPresenceConfirmation) =>
run("live", async () => {
const acquisition = state?.acquisition;
if (!acquisition?.acquisition_id || acquisition.state !== "prepared") {
throw new ApiError("Сначала сохраните проект и подготовьте локальный приём.");
}
return xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: physicalAcceptance,
});
}),
[run, state],
);
const startReplay = useCallback(
(request: ReplayRequest) => run("replay", () => xgridsK1Api.startReplay(request)),
[run],
);
const stop = useCallback(
(physicalAcceptance?: OperatorPresenceConfirmation) =>
run("stop", () => {
const acquisition = state?.acquisition;
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
if (acquisition && !acquisitionTerminal) {
const softwareCommanded = isSoftwareCommandedAcquisition(state);
if (softwareCommanded && !physicalAcceptance) {
throw new ApiError(
"Подтвердите присутствие рядом с K1 перед каноническим STOP.",
);
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: softwareCommanded ? "graceful" : "capture-only",
...(physicalAcceptance
? { physical_acceptance: physicalAcceptance }
: {}),
});
}
return xgridsK1Api.stopSessionCompatibility();
}),
[run, state],
);
const abort = useCallback(() => {
const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
return Promise.resolve(false);
}
return run("abort", () =>
xgridsK1Api.abortAcquisition({ acquisition_id: acquisition.acquisition_id }),
);
}, [run, state?.acquisition]);
const setObservationSourceActive = useCallback(
(sourceId: string, active: boolean) =>
run("camera", async () => {
if (!state) {
throw new ApiError("Состояние устройства ещё не загружено.");
}
const deviceSessionId = state.device_session?.device_session_id?.trim();
if (!deviceSessionId) {
throw new ApiError("Для камеры нет активной сессии устройства.");
}
const catalogSource = state.sensor_catalog?.streams?.find(
(candidate) => candidate.source_id === sourceId,
);
if (!catalogSource || catalogSource.activation?.controllable !== true) {
throw new ApiError("Плагин не разрешает управление выбранным видеоканалом.");
}
if (active) {
if (
catalogSource.activation.selected &&
state.camera_preview?.active_source_id === sourceId &&
state.camera_preview?.phase !== "error" &&
state.camera_preview?.delivery
) {
return state;
}
return xgridsK1Api.selectCameraPreview({
source_id: sourceId,
device_session_id: deviceSessionId,
});
}
if (state.camera_preview?.active_source_id !== sourceId) return state;
const generation = state.camera_preview.generation;
if (!Number.isInteger(generation) || (generation ?? 0) < 1) {
throw new ApiError("Плагин не вернул поколение активной camera-preview сессии.");
}
return xgridsK1Api.stopCameraPreview({
device_session_id: deviceSessionId,
generation: generation as number,
});
}),
[run, state],
);
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
[run],
);
useEffect(() => {
if (!enabled) {
operatorIntents.current.deactivateRuntime();
setState(null);
setBackendStatus("checking");
setEventStatus("closed");
setPendingAction(null);
setError(null);
setLatencyHistory([]);
return;
}
operatorIntents.current.activateRuntime();
void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000);
return () => {
operatorIntents.current.deactivateRuntime();
window.clearInterval(poll);
};
}, [enabled, refresh]);
useEffect(() => {
if (!enabled) return;
let dispose: (() => void) | undefined;
let retry: number | undefined;
let cancelled = false;
const connectEvents = () => {
if (cancelled) return;
dispose = openEventSocket(acceptState, (status) => {
if (cancelled) return;
setEventStatus(status);
if ((status === "closed" || status === "error") && retry === undefined) {
retry = window.setTimeout(() => {
retry = undefined;
connectEvents();
}, 3_000);
}
});
};
connectEvents();
return () => {
cancelled = true;
if (retry !== undefined) window.clearTimeout(retry);
dispose?.();
};
}, [acceptState, enabled]);
useEffect(() => {
const latency = measuredLatency(state);
if (latency === null) {
setLatencyHistory((values) => (values.length ? [] : values));
return;
}
setLatencyHistory((values) => [...values.slice(-23), latency]);
}, [state]);
return {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh: () => refresh(true),
clearError: () => setError(null),
scan,
connect,
openApplicationControlSession,
enterApplicationWorkspace,
closeApplicationControlSession,
startCanonicalAcquisition,
prepareAcquisition,
startPreparedAcquisition,
startReplay,
stop,
abort,
setObservationSourceActive,
updateViewerSettings,
};
}