Add baud and interface to lines, enhance line controls

Added 'baud' and 'interface' fields to the Line model and database schema. Implemented backend and frontend support for setting baud rate per line, including socket event handling and UI controls. Enhanced CardLine and BottomToolBar components to provide scenario and baud controls, and improved UI/UX for line management. Minor fixes and refactoring for consistency and usability.
This commit is contained in:
nguyentrungthat 2025-11-13 16:58:02 +07:00
parent 31036ff7da
commit 8a06650eab
14 changed files with 540 additions and 123 deletions

View File

@ -26,6 +26,12 @@ export default class Line extends BaseModel {
@column()
declare outlet: number
@column()
declare interface: string
@column()
declare baud: number
@belongsTo(() => Station)
declare station: BelongsTo<typeof Station>

View File

@ -100,7 +100,7 @@ class APCController {
this.buffer = ''
}
}
appendLog(data, 0, 0, this.apc_number || 0)
// appendLog(data, 0, 0, this.apc_number || 0)
}
private _handleClose(): void {

View File

@ -200,8 +200,10 @@ export default class LineConnection {
if (char === '\x7F') this.bufferCommand = this.bufferCommand.slice(0, -1)
else if (char === '\r' && cleanData(this.bufferCommand).length > 0) {
this.config.commands = [
cleanData(this.bufferCommand),
...this.config.commands.filter((el) => el !== cleanData(this.bufferCommand)),
cleanData(this.bufferCommand.replace('\r', '')),
...this.config.commands.filter(
(el) => el !== cleanData(this.bufferCommand.replace('\r', ''))
),
].slice(0, 10)
this.bufferCommand = ''
} else this.bufferCommand += char
@ -516,6 +518,7 @@ export default class LineConnection {
// Gửi nhiều ký tự ESC để vào ROMMON
breakSpam() {
console.log('SPAM Break to line:', this.config.lineNumber)
let count = 0
const escInterval = setInterval(() => {
if (count >= 100) {
@ -526,4 +529,16 @@ export default class LineConnection {
count++
}, 1)
}
async setBaud(baud: number) {
this.writeCommand('enable\r\n')
await sleep(500)
this.writeCommand('line console 0\r\n')
await sleep(500)
this.writeCommand(`speed ${baud}\r\n`)
await sleep(500)
this.writeCommand('end\r\n')
await sleep(500)
this.writeCommand('write memory\r\n')
}
}

View File

@ -0,0 +1,19 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'lines'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('interface').defaultTo('').nullable()
table.integer('baud').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('interface')
table.dropColumn('baud')
})
}
}

View File

@ -18,6 +18,7 @@ interface HandleOptions {
actionApc?: string
scenario?: any
timeout?: number
baud?: number
}
type LineAction = (line: LineConnection, options?: HandleOptions) => Promise<void | unknown> | void
@ -166,6 +167,26 @@ export class WebSocketIo {
)
})
socket.on('set_baud', async (data) => {
const lineId = data.lineId
const baud = data.baud
const line = await Line.find(lineId)
if (line) {
Object.assign(line, { baud })
line?.save()
}
await this.handleLineOperation(
io,
data.stationId,
[lineId],
async (value) => value.setBaud(baud),
{
baud,
timeout: 120000,
}
)
})
socket.on('open_cli', async (data) => {
const { lineId, userEmail, userName: name, stationId } = data
const line = this.lineMap.get(lineId)

View File

@ -112,10 +112,15 @@ function App() {
const response = await axios.get(apiUrl + "api/stations");
if (response.status) {
if (Array.isArray(response.data)) {
setStations(response.data);
response.data.forEach((station) => {
connectApcSwitch(station);
});
setStations(
response.data.map((station) => {
connectApcSwitch(station);
const lines = (station?.lines || []).sort(
(a: TLine, b: TLine) => a?.lineNumber - b?.lineNumber
);
return { ...station, lines };
})
);
}
}
} catch (error) {
@ -441,6 +446,7 @@ function App() {
loadingTerminal &&
Number(station.id) === Number(activeTab)
}
scenarios={scenarios}
/>
))}
</Flex>
@ -462,6 +468,7 @@ function App() {
loadingTerminal &&
Number(station.id) === Number(activeTab)
}
scenarios={scenarios}
/>
))}
</Flex>
@ -483,6 +490,7 @@ function App() {
loadingTerminal &&
Number(station.id) === Number(activeTab)
}
scenarios={scenarios}
/>
))}
</Flex>
@ -505,6 +513,7 @@ function App() {
isLogModalOpen={isLogModalOpen}
setIsLogModalOpen={setIsLogModalOpen}
setTestLogContent={setTestLogContent}
scenarios={scenarios}
/>
</Flex>
</Tabs.Panel>

View File

@ -4,29 +4,32 @@ import {
CloseButton,
Flex,
Input,
Menu,
ScrollArea,
Tabs,
Text,
} from "@mantine/core";
import { useState } from "react";
import { useMemo, useState } from "react";
import classes from "./Component.module.css";
import type { TLine, TStation } from "../untils/types";
import type { IScenario, TLine, TStation } from "../untils/types";
import type { Socket } from "socket.io-client";
import { ButtonDPELP, ButtonSelect } from "./ButtonAction";
import { ButtonDPELP, ButtonScenario, ButtonSelect } from "./ButtonAction";
import DrawerLogs from "./DrawerLogs";
import { DrawerAPCControl, DrawerSwitchControl } from "./DrawerControl";
import { isJsonString } from "../untils/helper";
interface TabsProps {
selectedLines: TLine[];
socket: Socket | null;
setSelectedLines: (lines: React.SetStateAction<TLine[]>) => void;
setSelectedLines: (value: React.SetStateAction<TLine[]>) => void;
isDisable: boolean;
station: TStation;
setIsDisable: (lines: React.SetStateAction<boolean>) => void;
setIsDisable: (value: React.SetStateAction<boolean>) => void;
testLogContent: string;
isLogModalOpen: boolean;
setIsLogModalOpen: (lines: React.SetStateAction<boolean>) => void;
setTestLogContent: (lines: React.SetStateAction<string>) => void;
setIsLogModalOpen: (value: React.SetStateAction<boolean>) => void;
setTestLogContent: (value: React.SetStateAction<string>) => void;
scenarios: IScenario[];
}
const BottomToolBar = ({
@ -40,7 +43,14 @@ const BottomToolBar = ({
isLogModalOpen,
setIsLogModalOpen,
setTestLogContent,
scenarios,
}: TabsProps) => {
const user = useMemo(() => {
return localStorage.getItem("user") &&
isJsonString(localStorage.getItem("user"))
? JSON.parse(localStorage.getItem("user") || "")
: null;
}, []);
const [valueInput, setValueInput] = useState<string>("");
const [activeTabBottom, setActiveBottom] = useState<string>("command");
@ -129,7 +139,7 @@ const BottomToolBar = ({
socket?.emit("write_command_line_from_web", {
lineIds: listLine.map((line) => line.id),
stationId: station.id,
command: " \n",
command: "spam_break",
});
setIsDisable(true);
setTimeout(() => {
@ -207,53 +217,58 @@ const BottomToolBar = ({
}, 5000);
}}
/>
<Button
disabled={isDisable || selectedLines.length === 0}
variant="outline"
color="green"
style={{ height: "30px", width: "100px" }}
onClick={() => {
if (selectedLines.length !== station.lines.length)
setSelectedLines(station.lines);
else setSelectedLines([]);
}}
>
Scenario
</Button>
{/* <Flex
w={"100%"}
direction={"column"}
wrap={"wrap"}
gap={"6px"}
>
{scenarios.map((el, i) => (
<ButtonScenario
key={i}
socket={socket}
selectedLines={selectedLines.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
)}
isDisable={
isDisable ||
selectedLines.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
).length === 0
}
onClick={() => {
setSelectedLines([]);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 5000);
}}
scenario={el}
/>
))}
</Flex> */}
<Menu shadow="md" position="top">
<Menu.Target>
<Button
disabled={isDisable || selectedLines.length === 0}
variant="filled"
color="yellow"
style={{ height: "30px", width: "100px" }}
onClick={() => {}}
>
Scenario
</Button>
</Menu.Target>
<Menu.Dropdown>
<Box
px="xs"
py="sm"
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "12px",
}}
>
{scenarios.map((el, i) => (
<ButtonScenario
key={i}
socket={socket}
selectedLines={selectedLines.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
)}
isDisable={
isDisable ||
selectedLines.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
).length === 0
}
onClick={() => {
setSelectedLines([]);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 5000);
}}
scenario={el}
/>
))}
</Box>
</Menu.Dropdown>
</Menu>
<DrawerLogs
socket={socket}
isLogModalOpen={isLogModalOpen}

View File

@ -133,7 +133,7 @@ export const ButtonScenario = ({
}: {
socket: Socket | null;
isDisable: boolean;
onClick: () => void;
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
selectedLines: TLine[];
scenario: IScenario;
}) => {
@ -146,8 +146,8 @@ export const ButtonScenario = ({
variant="outline"
color="#00a164"
className={classes.buttonScenario}
onClick={async () => {
onClick();
onClick={async (e) => {
onClick(e);
selectedLines?.forEach((el) => {
socket?.emit(
"run_scenario",

View File

@ -1,11 +1,13 @@
import { Card, Text, Box, Flex } from "@mantine/core";
import type { TLine, TStation } from "../untils/types";
import { Card, Text, Box, Flex, Menu, Button, Input } from "@mantine/core";
import type { IScenario, TLine, TStation } from "../untils/types";
import classes from "./Component.module.css";
import TerminalCLI from "./TerminalXTerm";
import type { Socket } from "socket.io-client";
import { IconCircleCheckFilled } from "@tabler/icons-react";
import { memo, useMemo } from "react";
import { memo, useMemo, useState } from "react";
import { convertTimestampToDate } from "../untils/helper";
import { ButtonDPELP, ButtonScenario } from "./ButtonAction";
import { notifications } from "@mantine/notifications";
import { listBaudDefault } from "../untils/constanst";
const CardLine = ({
line,
@ -15,6 +17,7 @@ const CardLine = ({
stationItem,
openTerminal,
loadTerminal,
scenarios,
}: {
line: TLine;
selectedLines: TLine[];
@ -23,6 +26,7 @@ const CardLine = ({
stationItem: TStation;
openTerminal: (value: TLine) => void;
loadTerminal: boolean;
scenarios: IScenario[];
}) => {
const user = useMemo(() => {
return localStorage.getItem("user") &&
@ -30,7 +34,51 @@ const CardLine = ({
? JSON.parse(localStorage.getItem("user") || "")
: null;
}, []);
const [showMenu, setShowMenu] = useState<boolean>(false);
const [isDisabled, setIsDisabled] = useState<boolean>(false);
const [valueBaud, setValueBaud] = useState<string>("");
const controlApc = (action: string) => {
if (!line.outlet) {
notifications.show({
title: "Error",
message: "Hasn't config outlet number",
color: "red",
});
return;
}
const apcName = line.apcName || line.apc_name;
if (!apcName) {
notifications.show({
title: "Error",
message: "Hasn't config apc",
color: "red",
});
return;
}
if (
(apcName === "apc_1" && !stationItem.apc_1_ip) ||
(apcName === "apc_2" && !stationItem.apc_2_ip)
) {
notifications.show({
title: "Error",
message: "Hasn't config apc ip",
color: "red",
});
return;
}
socket?.emit("control_apc", {
outletNumbers: [line.outlet],
station: stationItem,
action: action,
apcName: line.apcName || line.apc_name,
});
setIsDisabled(true);
setTimeout(() => {
setIsDisabled(false);
}, 5000);
};
console.log("RERENDER", line.lineNumber);
return (
<Card
key={line.id}
@ -55,6 +103,7 @@ const CardLine = ({
setSelectedLines(selectedLines.filter((val) => val.id !== line.id));
else setSelectedLines((pre) => [...pre, line]);
}}
onMouseLeave={() => setTimeout(() => setShowMenu(false), 150)}
>
<Flex
justify={"space-between"}
@ -62,54 +111,286 @@ const CardLine = ({
// gap={"md"}
// align={"center"}
>
<Flex justify={"space-between"}>
<Text
fw={600}
style={{ display: "flex", gap: "4px", fontSize: "15px" }}
>
Line: {line.lineNumber || line.line_number} - {line.port}{" "}
{line.status === "connected" && (
<IconCircleCheckFilled color="green" />
)}
</Text>
<div
style={{
alignItems: "center",
marginLeft: "16px",
fontSize: "12px",
color: "red",
display: "flex",
}}
>
{line?.userOpenCLI ? line?.userOpenCLI + " is using" : ""}
</div>
</Flex>
<Flex justify={"space-between"}>
<div className={classes.info_line}>
PID:{" "}
<Text className={classes.info_line} fs={"italic"}>
{line?.inventory?.pid || ""}
</Text>
</div>
<div className={classes.info_line}>
SN:{" "}
<Text className={classes.info_line} fs={"italic"}>
{line?.inventory?.sn || ""}
</Text>
</div>
<div className={classes.info_line} style={{ minWidth: "50px" }}>
VID:{" "}
<Text className={classes.info_line} fs={"italic"}>
{line?.inventory?.vid || ""}
</Text>
</div>
</Flex>
<Menu
shadow="md"
position="right-start"
transitionProps={{ transition: "pop-top-right" }}
opened={showMenu}
onChange={setShowMenu}
withArrow
arrowSize={8}
>
<Menu.Target>
<Flex
justify={"space-between"}
direction={"column"}
// className={classes.topBarLine}
onMouseEnter={() => setShowMenu(true)}
>
<Flex gap={"8px"}>
<Flex direction={"column"} justify={"center"} align={"center"}>
<Box>
<Text
fw={600}
style={{
fontSize: "24px",
lineHeight: "normal",
// color: line.status === "connected" ? "green" : "dark",
border: "1px solid #ccc",
borderRadius: "12px",
paddingLeft: "4px",
paddingRight: "4px",
backgroundColor:
line.status === "connected" ? "#4cc64c" : "",
}}
>
{line.lineNumber || line.line_number}
</Text>
</Box>
<Box>
<Text
fw={500}
style={{ fontSize: "12px", color: "#767676" }}
>
{line.port}
</Text>
</Box>
</Flex>
<Box w={"100%"}>
<Flex justify={"space-between"} w={"100%"}>
<div className={classes.info_line}>
PID:{" "}
<Text className={classes.info_line} fs={"italic"}>
{line?.inventory?.pid || ""}
</Text>
{line?.inventory?.vid ? (
<Text className={classes.info_line} fs={"italic"}>
{" | " + line?.inventory?.vid}
</Text>
) : (
""
)}
</div>
<div
className={classes.info_line}
style={{ width: "120px" }}
>
SN:{" "}
<Text className={classes.info_line} fs={"italic"}>
{line?.inventory?.sn || ""}
</Text>
</div>
</Flex>
<div
style={{
fontSize: "12px",
color: "red",
}}
>
{line?.userOpenCLI ? line?.userOpenCLI + " is using" : ""}
</div>
</Box>
</Flex>
</Flex>
</Menu.Target>
<Menu.Dropdown style={{ width: "110px", backgroundColor: "#2d2d2d" }}>
<Flex
justify={"space-between"}
direction={"column"}
style={{
gap: "8px",
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
// onMouseEnter={() => setShowMenu(true)}
// onMouseLeave={() => setTimeout(() => setShowMenu(false), 300)}
>
<Button
disabled={isDisabled}
variant="filled"
color="orange"
size="xs"
onClick={() => {
socket?.emit("write_command_line_from_web", {
lineIds: [line.id],
stationId: Number(stationItem.id),
command: "spam_break",
});
setIsDisabled(true);
setTimeout(() => {
setIsDisabled(false);
}, 5000);
}}
>
Send Break
</Button>
<ButtonDPELP
socket={socket}
selectedLines={[line]}
isDisable={isDisabled}
onClick={() => {
setIsDisabled(true);
setTimeout(() => {
setIsDisabled(false);
}, 5000);
}}
/>
<Menu shadow="md" position="right">
<Menu.Target>
<Button
disabled={isDisabled}
variant="filled"
color="yellow"
style={{ height: "30px", width: "100px" }}
onClick={() => {}}
>
Scenario
</Button>
</Menu.Target>
<Menu.Dropdown style={{ width: "130px" }}>
<Flex
justify={"space-between"}
direction={"column"}
style={{
gap: "8px",
}}
>
{scenarios.map((el, i) => (
<ButtonScenario
key={i}
socket={socket}
selectedLines={[line]}
isDisable={isDisabled}
onClick={() => {
setShowMenu(true);
setIsDisabled(true);
setTimeout(() => {
setIsDisabled(false);
}, 5000);
}}
scenario={el}
/>
))}
</Flex>
</Menu.Dropdown>
</Menu>
<Menu shadow="md" position="right">
<Menu.Target>
<Button
disabled={isDisabled}
variant="filled"
size="xs"
onClick={() => {}}
>
BAUD
</Button>
</Menu.Target>
<Menu.Dropdown style={{ width: "130px" }}>
<Flex
justify={"space-between"}
direction={"column"}
style={{
gap: "8px",
}}
>
{listBaudDefault.map((el, i) => (
<Button
key={i}
disabled={isDisabled}
variant="outline"
size="xs"
onClick={() => {
setShowMenu(true);
socket?.emit("set_baud", {
lineId: line.id,
baud: el,
});
setIsDisabled(true);
setTimeout(() => {
setIsDisabled(false);
}, 5000);
}}
>
{el}
</Button>
))}
<Input
placeholder="Custom"
value={valueBaud}
onChange={(e) => setValueBaud(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
socket?.emit("set_baud", {
lineId: line.id,
baud: Number(valueBaud),
});
setValueBaud("");
setIsDisabled(true);
setTimeout(() => {
setIsDisabled(false);
}, 5000);
}
}}
/>
</Flex>
</Menu.Dropdown>
</Menu>
<Flex justify={"space-between"} direction={"column"}>
<hr style={{ width: "100%" }} />
<Box style={{ textAlign: "center" }}>
<Text p={0} style={{ fontSize: "13px" }} c={"white"}>
Power
</Text>
</Box>
<Button
mt={"8px"}
disabled={isDisabled}
variant="outline"
color="green"
size="xs"
onClick={() => {
controlApc("on");
}}
>
ON
</Button>
<Button
mt={"8px"}
disabled={isDisabled}
variant="outline"
color="orange"
size="xs"
onClick={() => {
controlApc("off");
}}
>
OFF
</Button>
<Button
mt={"8px"}
disabled={isDisabled}
variant="outline"
color="red"
size="xs"
onClick={() => {
controlApc("restart");
}}
>
Restart
</Button>
</Flex>
</Flex>
</Menu.Dropdown>
</Menu>
<Box
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
style={{ height: "175px", width: "332px" }}
style={{ height: "160px", width: "332px" }}
>
<TerminalCLI
cliOpened={loadTerminal}
@ -130,8 +411,8 @@ const CardLine = ({
fontSize={11}
miniSize={true}
customStyle={{
maxHeight: "175px",
height: "175px",
maxHeight: "160px",
height: "160px",
fontSize: "7px",
padding: "0px",
paddingBottom: "0px",

View File

@ -10,6 +10,7 @@
color: dimgrey;
font-size: 11px;
display: flex;
align-items: center;
gap: 4px;
/* margin-top: 4px; */
height: 20px;
@ -119,3 +120,7 @@
border-radius: 8px;
background-color: #f3f3f38c;
}
.topBarLine:hover {
background-color: #f3f3f3;
}

View File

@ -229,8 +229,7 @@ export const DrawerAPCControl: React.FC<DrawerProps> = ({
APC 1
</Text>
{RenderAPCStatus(dataStation?.apc1)}
{dataStation?.apc1?.status === "DISCONNECTED" ||
dataStation?.apc1?.status === "TIMEOUT" ? (
{dataStation?.apc1?.status !== "CONNECTED" ? (
<Button
style={{ height: "24px" }}
size="xs"
@ -486,8 +485,7 @@ export const DrawerAPCControl: React.FC<DrawerProps> = ({
APC 2
</Text>
{RenderAPCStatus(dataStation?.apc2)}
{dataStation?.apc2?.status === "DISCONNECTED" ||
dataStation?.apc2?.status === "TIMEOUT" ? (
{dataStation?.apc2?.status !== "CONNECTED" ? (
<Button
style={{ height: "24px" }}
size="xs"
@ -883,6 +881,30 @@ export const DrawerSwitchControl: React.FC<DrawerProps> = ({
<Box ps={"8px"} pt={"4px"}>
{dataStation?.switch ? RenderAPCStatus(dataStation?.switch) : ""}
</Box>
{dataStation?.switch?.status !== "CONNECTED" ? (
<Button
size="xs"
disabled={isSubmit}
variant="filled"
color="yellow"
onClick={() => {
socket?.emit("control_switch", {
ports: [],
command: "reconnect",
station: stationAPI,
ip: stationAPI?.switch_control_ip,
});
setIsSubmit(true);
setTimeout(() => {
setIsSubmit(false);
}, 10000);
}}
>
<IconRepeat size={14} />
</Button>
) : (
""
)}
<Button
disabled={isSubmit}
title={

View File

@ -4,6 +4,7 @@ import {
Button,
Flex,
Group,
Input,
Modal,
NumberInput,
PasswordInput,
@ -97,6 +98,8 @@ const StationSetting = ({
apc_name: value.apcName || value.apc_name,
outlet: value.outlet,
station_id: value.stationId || value.station_id,
interface: value.interface,
baud: value.baud,
}));
setLines(dataLine);
}
@ -186,6 +189,20 @@ const StationSetting = ({
}
/>
</Table.Td>
<Table.Td fz={"sm"} p="3px" ta={"center"} fw={700}>
<Input
value={row?.interface}
onChange={(e) =>
setLines((pre) =>
pre.map((value, i) =>
i === index
? { ...value, interface: e.target.value }
: value
)
)
}
/>
</Table.Td>
<Table.Td fz={"sm"} p="3px" ta={"center"} fw={700}>
{row?.lineNumber ? (
<ActionIcon
@ -339,7 +356,7 @@ const StationSetting = ({
)}
</div>
}
size={"80%"}
size={"90%"}
style={{ position: "absolute", left: 0 }}
centered
opened={isOpen}
@ -648,21 +665,24 @@ const StationSetting = ({
>
<Table.Thead>
<Table.Tr>
<Table.Th fz={"sm"} w={"15%"} ta={"center"}>
Line number
<Table.Th fz={"sm"} w={"10%"} ta={"center"}>
Number
</Table.Th>
<Table.Th fz={"sm"} w={"15%"} ta={"center"}>
Port
</Table.Th>
<Table.Th fz={"sm"} w={"15%"} ta={"center"}>
<Table.Th fz={"sm"} w={"10%"} ta={"center"}>
Clear line
</Table.Th>
<Table.Th fz={"sm"} w={"15%"} ta={"center"}>
APC
</Table.Th>
<Table.Th fz={"sm"} w={"15%"} ta={"center"}>
<Table.Th fz={"sm"} w={"10%"} ta={"center"}>
Outlet
</Table.Th>
<Table.Th fz={"sm"} w={"15%"} ta={"center"}>
Interface
</Table.Th>
<Table.Th fz={"sm"} w={"10%"} ta={"center"}></Table.Th>
</Table.Tr>
</Table.Thead>

View File

@ -244,3 +244,5 @@ export const dataPermission = [
requiredPermissions: [],
},
];
export const listBaudDefault = [4800, 9600, 19200, 115200, 230400];

View File

@ -93,6 +93,8 @@ export type TLine = {
time: number;
};
commands?: string[];
interface?: string;
baud?: number;
};
export type TUser = {