feat(ocp): printer sync and logging logic added

This commit is contained in:
2026-04-13 15:34:18 -05:00
parent 87f738702a
commit 80189baf90
21 changed files with 8858 additions and 10 deletions

View File

@@ -2,6 +2,7 @@ NODE_ENV=development
# Server # Server
PORT=3000 PORT=3000
URL=http://localhost:3000 URL=http://localhost:3000
SERVER_IP=10.75.2.38
TIMEZONE=America/New_York TIMEZONE=America/New_York
TCP_PORT=2222 TCP_PORT=2222
@@ -22,7 +23,7 @@ DEFAULT_DOCK=
DEFAULT_LOAD_TYPE= DEFAULT_LOAD_TYPE=
DEFAULT_CARRIER= DEFAULT_CARRIER=
# prodServer when runing on an actual prod server use localhost this way we dont go out and back in. # prodServer when ruining on an actual prod server use localhost this way we don't go out and back in.
PROD_SERVER= PROD_SERVER=
PROD_PLANT_TOKEN= PROD_PLANT_TOKEN=
PROD_USER= PROD_USER=

View File

@@ -1,6 +1,11 @@
import { integer, pgTable, text } from "drizzle-orm/pg-core"; import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const opendockApt = pgTable("printer_log", { export const printerLog = pgTable("printer_log", {
id: integer().primaryKey().generatedAlwaysAsIdentity(), id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: text("name").notNull(), name: text("name"),
ip: text("ip"),
printerSN: text("printer_sn"),
condition: text("condition").notNull(),
message: text("message"),
createdAt: timestamp("created_at").defaultNow(),
}); });

View File

@@ -14,20 +14,82 @@
*/ */
import { Router } from "express"; import { Router } from "express";
import multer from "multer";
import { db } from "../db/db.controller.js";
import { printerLog } from "../db/schema/printerLogs.schema.js";
import { apiReturn } from "../utils/returnHelper.utils.js"; import { apiReturn } from "../utils/returnHelper.utils.js";
import { tryCatch } from "../utils/trycatch.utils.js";
type PrinterEvent = {
name: string;
condition: string;
message: string;
};
const r = Router(); const r = Router();
const upload = multer();
r.post("/printer/listener/:printer", async (req, res) => { const parseZebraAlert = (body: any): PrinterEvent => {
const name = body.uniqueId || "unknown";
const decoded = decodeURIComponent(body.alertMsg || "");
const [conditionRaw, ...rest] = decoded.split(":");
const condition = conditionRaw?.toLowerCase()?.trim() || "unknown";
const message = rest.join(":").trim();
return {
name,
condition,
message,
};
};
r.post("/printer/listener/:printer", upload.any(), async (req, res) => {
const { printer: printerName } = req.params; const { printer: printerName } = req.params;
console.log(req.body); const event: PrinterEvent = parseZebraAlert(req.body);
const rawIp =
req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim() ||
req.socket.remoteAddress ||
req.ip;
const ip = rawIp?.replace("::ffff:", "");
// post the new message
const { data, error } = await tryCatch(
db
.insert(printerLog)
.values({
ip: ip?.replace("::ffff:", ""),
name: printerName,
printerSN: event.name,
condition: event.condition,
message: event.message,
})
.returning(),
);
if (error) {
return apiReturn(res, {
success: false,
level: "info",
module: "ocp",
subModule: "printing",
message: `${printerName} encountered an error posting the log`,
data: error as any,
status: 400,
});
}
if (data) {
// TODO: send message over to the controller to decide what to do next with it
}
return apiReturn(res, { return apiReturn(res, {
success: true, success: true,
level: "info", level: "info",
module: "ocp", module: "ocp",
subModule: "printing", subModule: "printing",
message: `${printerName} just passed over a message`, message: `${printerName} just sent a message`,
data: req.body ?? [], data: req.body ?? [],
status: 200, status: 200,
}); });

View File

@@ -10,10 +10,323 @@
* printer status will live here this will be how we manage all the levels of status like 3 paused, 1 printing, 8 error, 10 power up, etc... * printer status will live here this will be how we manage all the levels of status like 3 paused, 1 printing, 8 error, 10 power up, etc...
*/ */
import { eq } from "drizzle-orm";
import net from "net";
import { db } from "../db/db.controller.js";
import { printerData } from "../db/schema/printers.schema.js";
import { createLogger } from "../logger/logger.controller.js";
import { delay } from "../utils/delay.utils.js";
import { runProdApi } from "../utils/prodEndpoint.utils.js";
import { returnFunc } from "../utils/returnHelper.utils.js";
type Printer = {
name: string;
humanReadableId: string;
type: number;
ipAddress: string;
port: number;
default: boolean;
labelInstanceIpAddress: string;
labelInstancePort: number;
active: boolean;
remark: string;
processes: number[];
};
const log = createLogger({ module: "ocp", subModule: "printers" });
export const printerManager = async () => {}; export const printerManager = async () => {};
export const printerHeartBeat = async () => { export const printerHeartBeat = async () => {
// heat heats will be defaulted to 60 seconds no reason to allow anything else // heat heats will be defaulted to 60 seconds no reason to allow anything else, and heart beats will only go to assigned printers no need to be monitoring non labeling printers
}; };
//export const printerStatus = async (statusNr: number, printerId: number) => {}; //export const printerStatus = async (statusNr: number, printerId: number) => {};
export const printerSync = async () => {
// pull the printers from alpla prod and update them in lst
const printers = await runProdApi({
method: "get",
endpoint: "/public/v1.0/Administration/Printers",
});
if (!printers?.success) {
return returnFunc({
success: false,
level: "error",
module: "ocp",
subModule: "printer",
message: printers?.message ?? "",
data: printers?.data ?? [],
notify: false,
});
}
if (printers?.success) {
const ignorePrinters = ["pdf24", "standard"];
const validPrinters =
printers.data.filter(
(n: any) =>
!ignorePrinters.includes(n.name.toLowerCase()) && n.ipAddress,
) ?? [];
if (validPrinters.length) {
for (const printer of validPrinters as Printer[]) {
// run an update for each printer, do on conflicts based on the printer id
log.debug({}, `Add/Updating ${printer.name}`);
if (printer.active) {
await db
.insert(printerData)
.values({
name: printer.name,
humanReadableId: printer.humanReadableId,
ipAddress: printer.ipAddress,
port: printer.port,
remark: printer.remark,
processes: printer.processes,
})
.onConflictDoUpdate({
target: printerData.humanReadableId,
set: {
name: printer.name,
humanReadableId: printer.humanReadableId,
ipAddress: printer.ipAddress,
port: printer.port,
remark: printer.remark,
processes: printer.processes,
},
})
.returning();
await tcpPrinter(printer);
}
if (!printer.active) {
log.warn({}, `${printer.name} is not active so removing from lst.`);
await db
.delete(printerData)
.where(eq(printerData.humanReadableId, printer.humanReadableId));
}
}
return returnFunc({
success: true,
level: "info",
module: "ocp",
subModule: "printer",
message: `${printers.data.length} printers were just synced, this includes new and old printers`,
data: [],
notify: false,
});
}
}
return returnFunc({
success: true,
level: "info",
module: "ocp",
subModule: "printer",
message: `No printers to update`,
data: [],
notify: false,
});
};
const tcpPrinter = (printer: Printer) => {
return new Promise<void>((resolve) => {
const socket = new net.Socket();
const timeoutMs = 15 * 1000;
const commands = [
{
key: "clearAlerts",
command: '! U1 setvar "alerts.configured" ""\r\n',
},
{
key: "addAlert",
command: `! U1 setvar "alerts.add" "ALL MESSAGES,HTTP-POST,Y,Y,http://${process.env.SERVER_IP}:${process.env.PORT}/lst/api/ocp/printer/listener/${printer.name},0,N,printer"\r\n`,
},
{
key: "setFriendlyName",
command: `! U1 setvar "device.friendly_name" "${printer.name}"\r\n`,
},
{
key: "getUniqueId",
command: '! U1 getvar "device.unique_id"\r\n',
},
] as const;
let currentCommandIndex = 0;
let awaitingSerial = false;
let settled = false;
const cleanup = () => {
socket.removeAllListeners();
socket.destroy();
};
const finish = (err?: unknown) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
cleanup();
if (err) {
log.error(
{ err, printer: printer.name },
`Printer update failed for ${printer.name}: doing the name and alert add directly on the printer.`,
);
}
resolve();
};
const timeout = setTimeout(() => {
finish(`${printer.name} timed out while updating printer config`);
}, timeoutMs);
const sendNext = async () => {
if (currentCommandIndex >= commands.length) {
socket.end();
return;
}
const current = commands[currentCommandIndex];
if (!current) {
socket.end();
return;
}
awaitingSerial = current.key === "getUniqueId";
log.info(
{ printer: printer.name, command: current.key },
`Sending command to ${printer.name}`,
);
socket.write(current.command);
currentCommandIndex++;
// Small pause between commands so the printer has breathing room
if (currentCommandIndex < commands.length) {
await delay(1500);
await sendNext();
} else {
// last command was sent, now wait for final data/close
await delay(1500);
socket.end();
}
};
socket.connect(printer.port, printer.ipAddress, async () => {
log.info({}, `Connected to ${printer.name}`);
try {
await sendNext();
} catch (error) {
finish(
error instanceof Error
? error
: new Error(
`Unknown error while sending commands to ${printer.name}`,
),
);
}
});
socket.on("data", async (data) => {
const response = data.toString().trim().replaceAll('"', "");
log.info(
{ printer: printer.name, response },
`Received printer response from ${printer.name}`,
);
if (!awaitingSerial) return;
awaitingSerial = false;
try {
await db
.update(printerData)
.set({ printerSN: response })
.where(eq(printerData.humanReadableId, printer.humanReadableId));
} catch (error) {
finish(
error instanceof Error
? error
: new Error(`Failed to update printer SN for ${printer.name}`),
);
}
});
socket.on("close", () => {
log.info({}, `Closed connection to ${printer.name}`);
finish();
});
socket.on("error", (err) => {
finish(err);
});
});
};
// const tcpPrinter = async (printer: Printer) => {
// const p = new net.Socket();
// const commands = [
// '! U1 setvar "alerts.configured" ""\r\n', // clean install just remove all alerts
// `! U1 setvar "alerts.add" "ALL MESSAGES,HTTP-POST,Y,Y,http://${process.env.SERVER_IP}:${process.env.PORT}/lst/api/ocp/printer/listener/${printer.name},0,N,printer"\r\n`, // add in the all alert
// `! U1 setvar "device.friendly_name" "${printer.name}"\r\n`, // change the name to match the alplaprod name
// `! U1 getvar "device.unique_id"\r\n`, // this will get mapped into the printer as this is the one we will link to in the db.
// //'! U1 getvar "alerts.configured" ""\r\n',
// ];
// let index = 0;
// const sendNext = async () => {
// if (index >= commands.length) {
// p.end();
// return;
// }
// const cmd = commands[index] as string;
// p.write(cmd);
// return;
// };
// p.connect(printer.port, printer.ipAddress, async () => {
// log.info({}, `Connected to ${printer.name}`);
// while (index < commands.length) {
// await sendNext();
// await delay(2000);
// index++;
// }
// });
// p.on("data", async (data) => {
// // this is just the sn that comes over so we will update this printer.
// await db
// .update(printerData)
// .set({ printerSN: data.toString().trim().replaceAll('"', "") })
// .where(eq(printerData.humanReadableId, printer.humanReadableId));
// // get the name
// // p.write('! U1 getvar "device.friendly_name"\r\n');
// // p.write('! U1 getvar "device.unique_id"\r\n');
// // p.write('! U1 getvar "alerts.configured"\r\n');
// });
// p.on("close", () => {
// log.info({}, `Closed connection to ${printer.name}`);
// p.destroy();
// return;
// });
// p.on("error", (err) => {
// log.info(
// { stack: err },
// `${printer.name} encountered an error while trying to update`,
// );
// return;
// });
// };

View File

@@ -0,0 +1,38 @@
/**
* the route that listens for the printers post.
*
* and http-post alert should be setup on each printer pointing to at min you will want to make the alert for
* pause printer, you can have all on here as it will also monitor and do things on all messages
*
* http://{serverIP}:2222/lst/api/ocp/printer/listener/{printerName}
*
* the messages will be sent over to the db for logging as well as specific ones will do something
*
* pause will validate if can print
* close head will repause the printer so it wont print a label
* power up will just repause the printer so it wont print a label
*/
import { Router } from "express";
import { apiReturn } from "../utils/returnHelper.utils.js";
//import { tryCatch } from "../utils/trycatch.utils.js";
import { printerSync } from "./ocp.printer.manage.js";
const r = Router();
r.post("/printer/update", async (_, res) => {
printerSync();
return apiReturn(res, {
success: true,
level: "info",
module: "ocp",
subModule: "printing",
message:
"Printer update has been triggered to monitor progress please head to the logs.",
data: [],
status: 200,
});
});
export default r;

View File

@@ -2,6 +2,7 @@ import { type Express, Router } from "express";
import { requireAuth } from "../middleware/auth.middleware.js"; import { requireAuth } from "../middleware/auth.middleware.js";
import { featureCheck } from "../middleware/featureActive.middleware.js"; import { featureCheck } from "../middleware/featureActive.middleware.js";
import listener from "./ocp.printer.listener.js"; import listener from "./ocp.printer.listener.js";
import update from "./ocp.printer.update.js";
export const setupOCPRoutes = (baseUrl: string, app: Express) => { export const setupOCPRoutes = (baseUrl: string, app: Express) => {
//setup all the routes //setup all the routes
@@ -16,6 +17,7 @@ export const setupOCPRoutes = (baseUrl: string, app: Express) => {
// auth routes below here // auth routes below here
router.use(requireAuth); router.use(requireAuth);
router.use(update);
//router.use(""); //router.use("");
app.use(`${baseUrl}/api/ocp`, router); app.use(`${baseUrl}/api/ocp`, router);

View File

@@ -8,12 +8,14 @@ import { connectGPSql } from "./gpSql/gpSqlConnection.controller.js";
import { createLogger } from "./logger/logger.controller.js"; import { createLogger } from "./logger/logger.controller.js";
import { startNotifications } from "./notification/notification.controller.js"; import { startNotifications } from "./notification/notification.controller.js";
import { createNotifications } from "./notification/notifications.master.js"; import { createNotifications } from "./notification/notifications.master.js";
import { printerSync } from "./ocp/ocp.printer.manage.js";
import { monitorReleaseChanges } from "./opendock/openDockRreleaseMonitor.utils.js"; import { monitorReleaseChanges } from "./opendock/openDockRreleaseMonitor.utils.js";
import { opendockSocketMonitor } from "./opendock/opendockSocketMonitor.utils.js"; import { opendockSocketMonitor } from "./opendock/opendockSocketMonitor.utils.js";
import { connectProdSql } from "./prodSql/prodSqlConnection.controller.js"; import { connectProdSql } from "./prodSql/prodSqlConnection.controller.js";
import { monitorAlplaPurchase } from "./purchase/purchase.controller.js"; import { monitorAlplaPurchase } from "./purchase/purchase.controller.js";
import { setupSocketIORoutes } from "./socket.io/serverSetup.js"; import { setupSocketIORoutes } from "./socket.io/serverSetup.js";
import { baseSettingValidationCheck } from "./system/settingsBase.controller.js"; import { baseSettingValidationCheck } from "./system/settingsBase.controller.js";
import { startTCPServer } from "./tcpServer/tcp.server.js";
import { createCronJob } from "./utils/croner.utils.js"; import { createCronJob } from "./utils/croner.utils.js";
import { sendEmail } from "./utils/sendEmail.utils.js"; import { sendEmail } from "./utils/sendEmail.utils.js";
@@ -29,6 +31,7 @@ const start = async () => {
const log = createLogger({ module: "system", subModule: "main start" }); const log = createLogger({ module: "system", subModule: "main start" });
// triggering long lived processes // triggering long lived processes
startTCPServer();
connectProdSql(); connectProdSql();
connectGPSql(); connectGPSql();
@@ -52,6 +55,10 @@ const start = async () => {
monitorAlplaPurchase(); monitorAlplaPurchase();
} }
if (systemSettings.filter((n) => n.name === "ocp")[0]?.active) {
printerSync();
}
// these jobs below are system jobs and should run no matter what. // these jobs below are system jobs and should run no matter what.
createCronJob("JobAuditLogCleanUp", "0 0 5 * * *", () => createCronJob("JobAuditLogCleanUp", "0 0 5 * * *", () =>
dbCleanup("jobs", 30), dbCleanup("jobs", 30),

View File

@@ -0,0 +1,51 @@
import { db } from "../db/db.controller.js";
import { printerLog } from "../db/schema/printerLogs.schema.js";
import { createLogger } from "../logger/logger.controller.js";
import { returnFunc } from "../utils/returnHelper.utils.js";
import { tryCatch } from "../utils/trycatch.utils.js";
export type PrinterData = {
ip: string;
name: string;
condition: string;
message: string;
date?: string;
printerSN: string;
};
const log = createLogger({ module: "tcp", submodule: "create_server" });
export const printerListen = async (tcpData: PrinterData) => {
const ip = tcpData.ip?.replace("::ffff:", "");
// post the new message
const { data, error } = await tryCatch(
db
.insert(printerLog)
.values({
ip,
name: tcpData.name,
condition: tcpData.condition,
message: tcpData.message,
printerSN: tcpData.printerSN,
})
.returning(),
);
if (error) {
return returnFunc({
success: false,
level: "error",
module: "tcp",
subModule: "post",
message: "Failed to post tcp printer data.",
data: [],
notify: false,
});
}
if (data) {
log.info({}, `${tcpData.name} sent a message over`);
// TODO: send message over to the controller to decide what to do next with it
}
};

View File

@@ -0,0 +1,117 @@
import net from "node:net";
import { eq } from "drizzle-orm";
import { db } from "../db/db.controller.js";
import { printerData } from "../db/schema/printers.schema.js";
import { createLogger } from "../logger/logger.controller.js";
import { returnFunc } from "../utils/returnHelper.utils.js";
import { tryCatch } from "../utils/trycatch.utils.js";
import { type PrinterData, printerListen } from "./tcp.printerListener.js";
let tcpServer: net.Server;
const tcpSockets: Set<net.Socket> = new Set();
//let isServerRunning = false;
const port = parseInt(process.env.TCP_PORT ?? "2222", 10);
const parseTcpAlert = (input: string) => {
// guard
const colonIndex = input.indexOf(":");
if (colonIndex === -1) return null;
const condition = input.slice(0, colonIndex).trim();
const rest = input.slice(colonIndex + 1).trim();
// extract all [ ... ] blocks from rest
const matches = [...rest.matchAll(/\[(.*?)\]/g)];
const date = matches[0]?.[1] ?? "";
const name = matches[1]?.[1] ?? "";
// message = everything before first "["
const bracketIndex = rest.indexOf("[");
const message =
bracketIndex !== -1 ? rest.slice(0, bracketIndex).trim() : rest;
return {
condition,
message,
date,
name,
};
};
export const startTCPServer = () => {
const log = createLogger({ module: "tcp", submodule: "create_server" });
tcpServer = net.createServer(async (socket) => {
tcpSockets.add(socket);
socket.on("data", async (data: Buffer) => {
const parseData = data.toString("utf-8").trimEnd();
// check where the data came from then we do something.
const ip = socket.remoteAddress ?? "127.0.0.1";
const { data: printer, error: pError } = await tryCatch(
db
.select()
.from(printerData)
.where(eq(printerData.ipAddress, ip.replace("::ffff:", ""))),
);
if (pError) {
log.error(
{ stack: pError },
"There was an error getting printer data for tcp check",
);
return;
}
if (printer?.length) {
const printerData = {
...parseTcpAlert(parseData),
ip,
printerSN: printer[0]?.printerSN,
name: printer[0]?.name,
};
printerListen(printerData as PrinterData);
}
});
socket.on("end", () => {
log.debug({}, "Client disconnected");
// just in case we dont fully disconnect
setTimeout(() => {
if (!socket.destroyed) {
socket.destroy();
}
}, 1000);
tcpSockets.delete(socket);
});
socket.on("error", (err: Error) => {
log.error({ stack: err }, `Socket error:", ${err}`);
// just in case we dont fully disconnect
setTimeout(() => {
if (!socket.destroyed) {
socket.destroy();
}
}, 1000);
tcpSockets.delete(socket);
});
});
tcpServer.listen(port, () => {
log.info({}, `TCP Server listening on port ${port}`);
});
//isServerRunning = true;
return returnFunc({
success: true,
level: "info",
module: "tcp",
subModule: "create_server",
message: "TCP server started.",
data: [],
notify: false,
room: "",
});
};

View File

@@ -12,7 +12,8 @@ interface Data<T = unknown[]> {
| "opendock" | "opendock"
| "notification" | "notification"
| "email" | "email"
| "purchase"; | "purchase"
| "tcp";
subModule: subModule:
| "db" | "db"
| "labeling" | "labeling"
@@ -34,7 +35,9 @@ interface Data<T = unknown[]> {
| "printing" | "printing"
| "gpSql" | "gpSql"
| "email" | "email"
| "gpChecks"; | "gpChecks"
| "prodEndpoint"
| "create_server";
level: "info" | "error" | "debug" | "fatal"; level: "info" | "error" | "debug" | "fatal";
message: string; message: string;
room?: string; room?: string;

View File

@@ -0,0 +1,24 @@
CREATE TABLE "printer_data" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"humanReadable_id" text NOT NULL,
"name" text NOT NULL,
"ipAddress" text,
"port" numeric,
"status" text,
"statusText" text,
"last_time_printed" timestamp DEFAULT now() NOT NULL,
"assigned" boolean DEFAULT false,
"remark" text,
"printDelay" numeric DEFAULT '90',
"processes" jsonb DEFAULT '[]'::jsonb,
"print_delay_override" boolean DEFAULT false,
"add_Date" timestamp DEFAULT now(),
"upd_date" timestamp DEFAULT now(),
CONSTRAINT "printer_data_humanReadable_id_unique" UNIQUE("humanReadable_id")
);
--> statement-breakpoint
ALTER TABLE "printer_log" RENAME COLUMN "name" TO "ip";--> statement-breakpoint
ALTER TABLE "printer_log" ADD COLUMN "printer_sn" text;--> statement-breakpoint
ALTER TABLE "printer_log" ADD COLUMN "condition" text NOT NULL;--> statement-breakpoint
ALTER TABLE "printer_log" ADD COLUMN "message" text;--> statement-breakpoint
CREATE UNIQUE INDEX "printer_id" ON "printer_data" USING btree ("humanReadable_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "printer_log" ADD COLUMN "name" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "printer_data" ADD COLUMN "printer_sn" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "printer_data" ALTER COLUMN "port" SET DATA TYPE integer;--> statement-breakpoint
ALTER TABLE "printer_data" ALTER COLUMN "printDelay" SET DATA TYPE integer;--> statement-breakpoint
ALTER TABLE "printer_data" ALTER COLUMN "printDelay" SET DEFAULT 90;

View File

@@ -0,0 +1 @@
ALTER TABLE "printer_log" ADD COLUMN "created_at" timestamp DEFAULT now();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -190,6 +190,41 @@
"when": 1775786221817, "when": 1775786221817,
"tag": "0026_vengeful_wiccan", "tag": "0026_vengeful_wiccan",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1776085181774,
"tag": "0027_free_green_goblin",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1776085921073,
"tag": "0028_keen_the_fallen",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1776087535705,
"tag": "0029_gigantic_thunderbolt_ross",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1776087914328,
"tag": "0030_high_invisible_woman",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1776098377074,
"tag": "0031_numerous_the_phantom",
"breakpoints": true
} }
] ]
} }