Implement support for device-profile templates + TTN importer.

This commit is contained in:
Orne Brocaar
2022-06-07 19:28:41 +01:00
parent d1630e5722
commit d9d3f14e80
59 changed files with 12091 additions and 282 deletions

View File

@ -19,7 +19,7 @@
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.3.3",
"antd": "^4.20.1",
"antd": "^4.20.6",
"antd-mask-input": "^2.0.7",
"buffer": "^6.0.3",
"chart.js": "^3.7.1",

View File

@ -30,6 +30,11 @@ import ChangeUserPassword from "./views/users/ChangeUserPassword";
import ListAdminApiKeys from "./views/api-keys/ListAdminApiKeys";
import CreateAdminApiKey from "./views/api-keys/CreateAdminApiKey";
// device-profile templates
import ListDeviceProfileTemplates from "./views/device-profile-templates/ListDeviceProfileTemplates";
import EditDeviceProfileTemplate from "./views/device-profile-templates/EditDeviceProfileTemplate";
import CreateDeviceProfileTemplate from "./views/device-profile-templates/CreateDeviceProfileTemplate";
// stores
import SessionStore from "./stores/SessionStore";
@ -93,6 +98,14 @@ class App extends Component<IProps, IState> {
<Route exact path="/api-keys" component={ListAdminApiKeys} />
<Route exact path="/api-keys/create" component={CreateAdminApiKey} />
<Route exact path="/device-profile-templates" component={ListDeviceProfileTemplates} />
<Route exact path="/device-profile-templates/create" component={CreateDeviceProfileTemplate} />
<Route
exact
path="/device-profile-templates/:deviceProfileTemplateId([\w-]+)/edit"
component={EditDeviceProfileTemplate}
/>
</Switch>
</Layout.Content>
</Layout>

View File

@ -35,6 +35,18 @@ class CodeEditor extends Component<IProps, IState> {
}
}
componentDidUpdate(oldProps: IProps) {
if (this.props === oldProps) {
return;
}
if (this.props.value) {
this.setState({
value: this.props.value,
});
}
}
updateField = () => {
let value = this.state.value;

View File

@ -118,6 +118,11 @@ class SideMenu extends Component<RouteComponentProps, IState> {
this.setState({ selectedKey: "ns-api-keys" });
}
// ns device-profile templates
if (/\/device-profile-templates(\/([\w-]{36}\/edit|create))?/g.exec(path)) {
this.setState({ selectedKey: "ns-device-profile-templates" });
}
// tenant dashboard
if (/\/tenants\/[\w-]{36}/g.exec(path)) {
this.setState({ selectedKey: "tenant-dashboard" });
@ -163,6 +168,11 @@ class SideMenu extends Component<RouteComponentProps, IState> {
{ key: "ns-tenants", icon: <HomeOutlined />, label: <Link to="/tenants">Tenants</Link> },
{ key: "ns-users", icon: <UserOutlined />, label: <Link to="/users">Users</Link> },
{ key: "ns-api-keys", icon: <KeyOutlined />, label: <Link to="/api-keys">API keys</Link> },
{
key: "ns-device-profile-templates",
icon: <ControlOutlined />,
label: <Link to="/device-profile-templates">Device-profile templates</Link>,
},
],
});
}

View File

@ -0,0 +1,97 @@
import { notification } from "antd";
import { EventEmitter } from "events";
import { DeviceProfileTemplateServiceClient } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_template_grpc_web_pb";
import {
CreateDeviceProfileTemplateRequest,
GetDeviceProfileTemplateRequest,
GetDeviceProfileTemplateResponse,
UpdateDeviceProfileTemplateRequest,
DeleteDeviceProfileTemplateRequest,
ListDeviceProfileTemplatesRequest,
ListDeviceProfileTemplatesResponse,
} from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_template_pb";
import SessionStore from "./SessionStore";
import { HandleError } from "./helpers";
class DeviceProfileTemplateStore extends EventEmitter {
client: DeviceProfileTemplateServiceClient;
constructor() {
super();
this.client = new DeviceProfileTemplateServiceClient("");
}
create = (req: CreateDeviceProfileTemplateRequest, callbackFunc: () => void) => {
this.client.create(req, SessionStore.getMetadata(), (err, resp) => {
if (err !== null) {
HandleError(err);
return;
}
notification.success({
message: "Device-profile template created",
duration: 3,
});
callbackFunc();
});
};
get = (req: GetDeviceProfileTemplateRequest, callbackFunc: (resp: GetDeviceProfileTemplateResponse) => void) => {
this.client.get(req, SessionStore.getMetadata(), (err, resp) => {
if (err !== null) {
HandleError(err);
return;
}
callbackFunc(resp);
});
};
update = (req: UpdateDeviceProfileTemplateRequest, callbackFunc: () => void) => {
this.client.update(req, SessionStore.getMetadata(), err => {
if (err !== null) {
HandleError(err);
return;
}
notification.success({
message: "Device-profile template updated",
duration: 3,
});
callbackFunc();
});
};
delete = (req: DeleteDeviceProfileTemplateRequest, callbackFunc: () => void) => {
this.client.delete(req, SessionStore.getMetadata(), err => {
if (err !== null) {
HandleError(err);
return;
}
notification.success({
message: "Device-profile template deleted",
duration: 3,
});
callbackFunc();
});
};
list = (req: ListDeviceProfileTemplatesRequest, callbackFunc: (resp: ListDeviceProfileTemplatesResponse) => void) => {
this.client.list(req, SessionStore.getMetadata(), (err, resp) => {
if (err !== null) {
HandleError(err);
return;
}
callbackFunc(resp);
});
};
}
const deviceProfileTemplateStore = new DeviceProfileTemplateStore();
export default deviceProfileTemplateStore;

View File

@ -91,7 +91,7 @@ class ListAdminApiKeys extends Component<IProps, IState> {
breadcrumbRender={() => (
<Breadcrumb>
<Breadcrumb.Item>
<span>Network-server</span>
<span>Network Server</span>
</Breadcrumb.Item>
<Breadcrumb.Item>
<span>API keys</span>

View File

@ -0,0 +1,96 @@
import React, { Component } from "react";
import { Link, RouteComponentProps } from "react-router-dom";
import { Space, Breadcrumb, Card, PageHeader } from "antd";
import { MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
import {
DeviceProfileTemplate,
CreateDeviceProfileTemplateRequest,
} from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_template_pb";
import DeviceProfileTemplateForm from "./DeviceProfileTemplateForm";
import DeviceProfileTemplateStore from "../../stores/DeviceProfileTemplateStore";
class CreateDeviceProfileTemplate extends Component<RouteComponentProps> {
onFinish = (obj: DeviceProfileTemplate) => {
let req = new CreateDeviceProfileTemplateRequest();
req.setDeviceProfileTemplate(obj);
DeviceProfileTemplateStore.create(req, () => {
this.props.history.push(`/device-profile-templates`);
});
};
render() {
const codecScript = `// Decode uplink function.
//
// Input is an object with the following fields:
// - bytes = Byte array containing the uplink payload, e.g. [255, 230, 255, 0]
// - fPort = Uplink fPort.
// - variables = Object containing the configured device variables.
//
// Output must be an object with the following fields:
// - data = Object representing the decoded payload.
function decodeUplink(input) {
return {
object: {
temp: 22.5
}
};
}
// Encode downlink function.
//
// Input is an object with the following fields:
// - data = Object representing the payload that must be encoded.
// - variables = Object containing the configured device variables.
//
// Output must be an object with the following fields:
// - bytes = Byte array containing the downlink payload.
function encodeDownlink(input) {
return {
data: [225, 230, 255, 0]
};
}
`;
let deviceProfileTemplate = new DeviceProfileTemplate();
deviceProfileTemplate.setPayloadCodecScript(codecScript);
deviceProfileTemplate.setSupportsOtaa(true);
deviceProfileTemplate.setUplinkInterval(3600);
deviceProfileTemplate.setDeviceStatusReqInterval(1);
deviceProfileTemplate.setAdrAlgorithmId("default");
deviceProfileTemplate.setMacVersion(MacVersion.LORAWAN_1_0_3);
deviceProfileTemplate.setRegParamsRevision(RegParamsRevision.A);
deviceProfileTemplate.setFlushQueueOnActivate(true);
return (
<Space direction="vertical" style={{ width: "100%" }} size="large">
<PageHeader
breadcrumbRender={() => (
<Breadcrumb>
<Breadcrumb.Item>
<span>Network Server</span>
</Breadcrumb.Item>
<Breadcrumb.Item>
<span>
<Link to={`/device-profile-templates`}>Device-profile templates</Link>
</span>
</Breadcrumb.Item>
<Breadcrumb.Item>
<span>Add</span>
</Breadcrumb.Item>
</Breadcrumb>
)}
title="Add device-profile template"
/>
<Card>
<DeviceProfileTemplateForm initialValues={deviceProfileTemplate} onFinish={this.onFinish} />
</Card>
</Space>
);
}
}
export default CreateDeviceProfileTemplate;

View File

@ -0,0 +1,419 @@
import React, { Component } from "react";
import { Form, Input, Select, InputNumber, Switch, Row, Col, Button, Tabs } from "antd";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { DeviceProfileTemplate } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_template_pb";
import { CodecRuntime } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import { Region, MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
import { ListDeviceProfileAdrAlgorithmsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import DeviceProfileStore from "../../stores/DeviceProfileStore";
import CodeEditor from "../../components/CodeEditor";
interface IProps {
initialValues: DeviceProfileTemplate;
onFinish: (obj: DeviceProfileTemplate) => void;
update?: boolean;
}
interface IState {
supportsOtaa: boolean;
supportsClassB: boolean;
supportsClassC: boolean;
payloadCodecRuntime: CodecRuntime;
adrAlgorithms: [string, string][];
}
class DeviceProfileTemplateForm extends Component<IProps, IState> {
formRef = React.createRef<any>();
constructor(props: IProps) {
super(props);
this.state = {
supportsOtaa: false,
supportsClassB: false,
supportsClassC: false,
payloadCodecRuntime: CodecRuntime.NONE,
adrAlgorithms: [],
};
}
componentDidMount() {
const v = this.props.initialValues;
this.setState({
supportsOtaa: v.getSupportsOtaa(),
supportsClassB: v.getSupportsClassB(),
supportsClassC: v.getSupportsClassC(),
payloadCodecRuntime: v.getPayloadCodecRuntime(),
});
DeviceProfileStore.listAdrAlgorithms((resp: ListDeviceProfileAdrAlgorithmsResponse) => {
let adrAlgorithms: [string, string][] = [];
for (const a of resp.getResultList()) {
adrAlgorithms.push([a.getId(), a.getName()]);
}
this.setState({
adrAlgorithms: adrAlgorithms,
});
});
}
onFinish = (values: DeviceProfileTemplate.AsObject) => {
const v = Object.assign(this.props.initialValues.toObject(), values);
let dp = new DeviceProfileTemplate();
dp.setId(v.id);
dp.setName(v.name);
dp.setDescription(v.description);
dp.setVendor(v.vendor);
dp.setFirmware(v.firmware);
dp.setRegion(v.region);
dp.setMacVersion(v.macVersion);
dp.setRegParamsRevision(v.regParamsRevision);
dp.setAdrAlgorithmId(v.adrAlgorithmId);
dp.setFlushQueueOnActivate(v.flushQueueOnActivate);
dp.setUplinkInterval(v.uplinkInterval);
dp.setDeviceStatusReqInterval(v.deviceStatusReqInterval);
// join otaa /abp
dp.setSupportsOtaa(v.supportsOtaa);
dp.setAbpRx1Delay(v.abpRx1Delay);
dp.setAbpRx1DrOffset(v.abpRx1DrOffset);
dp.setAbpRx2Dr(v.abpRx2Dr);
dp.setAbpRx2Freq(v.abpRx2Freq);
// class-b
dp.setSupportsClassB(v.supportsClassB);
dp.setClassBTimeout(v.classBTimeout);
// class-c
dp.setSupportsClassC(v.supportsClassC);
dp.setClassCTimeout(v.classCTimeout);
// codec
dp.setPayloadCodecRuntime(v.payloadCodecRuntime);
dp.setPayloadCodecScript(v.payloadCodecScript);
// tags
for (const elm of v.tagsMap) {
dp.getTagsMap().set(elm[0], elm[1]);
}
this.props.onFinish(dp);
};
onSupportsOtaaChange = (checked: boolean) => {
this.setState({
supportsOtaa: checked,
});
};
onSupportsClassBChnage = (checked: boolean) => {
this.setState({
supportsClassB: checked,
});
};
onSupportsClassCChange = (checked: boolean) => {
this.setState({
supportsClassC: checked,
});
};
onPayloadCodecRuntimeChange = (value: CodecRuntime) => {
this.setState({
payloadCodecRuntime: value,
});
};
render() {
const adrOptions = this.state.adrAlgorithms.map(v => <Select.Option value={v[0]}>{v[1]}</Select.Option>);
return (
<Form
layout="vertical"
initialValues={this.props.initialValues.toObject()}
onFinish={this.onFinish}
ref={this.formRef}
>
<Tabs>
<Tabs.TabPane tab="General" key="1">
<Form.Item
label="ID"
name="id"
rules={[
{
required: true,
pattern: new RegExp(/^[\w-]*$/g),
message: "Please enter a valid id!",
},
]}
>
<Input disabled={!!this.props.update} />
</Form.Item>
<Form.Item label="Name" name="name" rules={[{ required: true, message: "Please enter a name!" }]}>
<Input />
</Form.Item>
<Form.Item label="Vendor" name="vendor" rules={[{ required: true, message: "Please enter a vendor!" }]}>
<Input />
</Form.Item>
<Form.Item
label="Firmware version"
name="firmware"
rules={[{ required: true, message: "Please enter a firmware version!" }]}
>
<Input />
</Form.Item>
<Form.Item label="Description" name="description">
<Input.TextArea rows={6} />
</Form.Item>
<Form.Item label="Region" name="region" rules={[{ required: true, message: "Please select a region!" }]}>
<Select>
<Select.Option value={Region.AS923}>AS923</Select.Option>
<Select.Option value={Region.AS923_2}>AS923-2</Select.Option>
<Select.Option value={Region.AS923_3}>AS923-3</Select.Option>
<Select.Option value={Region.AS923_4}>AS923-4</Select.Option>
<Select.Option value={Region.AU915}>AU915</Select.Option>
<Select.Option value={Region.CN779}>CN779</Select.Option>
<Select.Option value={Region.EU433}>EU433</Select.Option>
<Select.Option value={Region.EU868}>EU868</Select.Option>
<Select.Option value={Region.IN865}>IN865</Select.Option>
<Select.Option value={Region.ISM2400}>ISM2400</Select.Option>
<Select.Option value={Region.KR920}>KR920</Select.Option>
<Select.Option value={Region.RU864}>RU864</Select.Option>
<Select.Option value={Region.US915}>US915</Select.Option>
</Select>
</Form.Item>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label="MAC version"
tooltip="The LoRaWAN MAC version supported by the device."
name="macVersion"
rules={[{ required: true, message: "Please select a MAC version!" }]}
>
<Select>
<Select.Option value={MacVersion.LORAWAN_1_0_0}>LoRaWAN 1.0.0</Select.Option>
<Select.Option value={MacVersion.LORAWAN_1_0_1}>LoRaWAN 1.0.1</Select.Option>
<Select.Option value={MacVersion.LORAWAN_1_0_2}>LoRaWAN 1.0.2</Select.Option>
<Select.Option value={MacVersion.LORAWAN_1_0_3}>LoRaWAN 1.0.3</Select.Option>
<Select.Option value={MacVersion.LORAWAN_1_0_4}>LoRaWAN 1.0.4</Select.Option>
<Select.Option value={MacVersion.LORAWAN_1_1_0}>LoRaWAN 1.1.0</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Regional parameters revision"
tooltip="Revision of the Regional Parameters specification supported by the device."
name="regParamsRevision"
rules={[{ required: true, message: "Please select a regional parameters revision!" }]}
>
<Select>
<Select.Option value={RegParamsRevision.A}>A</Select.Option>
<Select.Option value={RegParamsRevision.B}>B</Select.Option>
<Select.Option value={RegParamsRevision.RP002_1_0_0}>RP002-1.0.0</Select.Option>
<Select.Option value={RegParamsRevision.RP002_1_0_1}>RP002-1.0.1</Select.Option>
<Select.Option value={RegParamsRevision.RP002_1_0_2}>RP002-1.0.2</Select.Option>
<Select.Option value={RegParamsRevision.RP002_1_0_3}>RP002-1.0.3</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
label="ADR algorithm"
tooltip="The ADR algorithm that will be used for controlling the device data-rate."
name="adrAlgorithmId"
rules={[{ required: true, message: "Please select an ADR algorithm!" }]}
>
<Select>{adrOptions}</Select>
</Form.Item>
<Row gutter={24}>
<Col span={8}>
<Form.Item
label="Flush queue on activate"
name="flushQueueOnActivate"
valuePropName="checked"
tooltip="If enabled, the device-queue will be flushed on ABP or OTAA activation."
>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
label="Expected uplink interval (secs)"
tooltip="The expected interval in seconds in which the device sends uplink messages. This is used to determine if a device is active or inactive."
name="uplinkInterval"
rules={[{ required: true, message: "Please enter an uplink interval!" }]}
>
<InputNumber min={0} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
label="Device-status request frequency (req/day)"
tooltip="Frequency to initiate an End-Device status request (request/day). Set to 0 to disable."
name="deviceStatusReqInterval"
>
<InputNumber min={0} />
</Form.Item>
</Col>
</Row>
</Tabs.TabPane>
<Tabs.TabPane tab="Join (OTAA / ABP)" key="2">
<Form.Item label="Device supports OTAA" name="supportsOtaa" valuePropName="checked">
<Switch onChange={this.onSupportsOtaaChange} />
</Form.Item>
{!this.state.supportsOtaa && (
<Row>
<Col span={12}>
<Form.Item
label="RX1 delay"
name="abpRx1Delay"
rules={[{ required: true, message: "Please enter a RX1 delay!" }]}
>
<InputNumber min={0} max={15} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="RX1 data-rate offset"
tooltip="Please refer the LoRaWAN Regional Parameters specification for valid values."
name="abpRx1DrOffset"
rules={[{ required: true, message: "Please enter a RX1 data-rate offset!" }]}
>
<InputNumber min={0} max={15} />
</Form.Item>
</Col>
</Row>
)}
{!this.state.supportsOtaa && (
<Row>
<Col span={12}>
<Form.Item
label="RX2 data-rate"
tooltip="Please refer the LoRaWAN Regional Parameters specification for valid values."
name="abpRx2Dr"
rules={[{ required: true, message: "Please enter a RX2 data-rate!" }]}
>
<InputNumber min={0} max={15} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="RX2 frequency (Hz)"
name="abpRx2Freq"
rules={[{ required: true, message: "Please enter a RX2 frequency!" }]}
>
<InputNumber min={0} style={{ width: "200px" }} />
</Form.Item>
</Col>
</Row>
)}
</Tabs.TabPane>
<Tabs.TabPane tab="Class-B" key="3">
<Form.Item label="Device supports Class-B" name="supportsClassB" valuePropName="checked">
<Switch onChange={this.onSupportsClassBChnage} />
</Form.Item>
{this.state.supportsClassB && (
<Form.Item
label="Class-B confirmed downlink timeout (seconds)"
tooltip="Class-B timeout (in seconds) for confirmed downlink transmissions."
name="classBTimeout"
rules={[{ required: true, message: "Please enter a Class-B confirmed downlink timeout!" }]}
>
<InputNumber min={0} />
</Form.Item>
)}
</Tabs.TabPane>
<Tabs.TabPane tab="Class-C" key="4">
<Form.Item label="Device supports Class-C" name="supportsClassC" valuePropName="checked">
<Switch onChange={this.onSupportsClassCChange} />
</Form.Item>
{this.state.supportsClassC && (
<Form.Item
label="Class-C confirmed downlink timeout (seconds)"
tooltip="Class-C timeout (in seconds) for confirmed downlink transmissions."
name="classCTimeout"
rules={[{ required: true, message: "Please enter a Class-C confirmed downlink timeout!" }]}
>
<InputNumber min={0} />
</Form.Item>
)}
</Tabs.TabPane>
<Tabs.TabPane tab="Codec" key="5">
<Form.Item
label="Payload codec"
name="payloadCodecRuntime"
tooltip="By defining a payload codec, ChirpStack Application Server can encode and decode the binary device payload for you."
>
<Select onChange={this.onPayloadCodecRuntimeChange}>
<Select.Option value={CodecRuntime.NONE}>None</Select.Option>
<Select.Option value={CodecRuntime.CAYENNE_LPP}>Cayenne LPP</Select.Option>
<Select.Option value={CodecRuntime.JS}>JavaScript functions</Select.Option>
</Select>
</Form.Item>
{this.state.payloadCodecRuntime === CodecRuntime.JS && (
<CodeEditor
label="Codec functions"
name="payloadCodecScript"
value={this.formRef.current.getFieldValue("payloadCodecScript")}
formRef={this.formRef}
/>
)}
</Tabs.TabPane>
<Tabs.TabPane tab="Tags" key="6">
<Form.List name="tagsMap">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Row gutter={24}>
<Col span={6}>
<Form.Item
{...restField}
name={[name, 0]}
fieldKey={[name, 0]}
rules={[{ required: true, message: "Please enter a key!" }]}
>
<Input placeholder="Key" />
</Form.Item>
</Col>
<Col span={16}>
<Form.Item
{...restField}
name={[name, 1]}
fieldKey={[name, 1]}
rules={[{ required: true, message: "Please enter a value!" }]}
>
<Input placeholder="Value" />
</Form.Item>
</Col>
<Col span={2}>
<MinusCircleOutlined onClick={() => remove(name)} />
</Col>
</Row>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add tag
</Button>
</Form.Item>
</>
)}
</Form.List>
</Tabs.TabPane>
</Tabs>
<Form.Item>
<Button type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</Form>
);
}
}
export default DeviceProfileTemplateForm;

View File

@ -0,0 +1,115 @@
import React, { Component } from "react";
import { RouteComponentProps, Link } from "react-router-dom";
import { Space, Breadcrumb, Card, Button, PageHeader } from "antd";
import {
DeviceProfileTemplate,
GetDeviceProfileTemplateRequest,
GetDeviceProfileTemplateResponse,
UpdateDeviceProfileTemplateRequest,
DeleteDeviceProfileTemplateRequest,
} from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_template_pb";
import DeviceProfileTemplateForm from "./DeviceProfileTemplateForm";
import DeviceProfileTemplateStore from "../../stores/DeviceProfileTemplateStore";
import DeleteConfirm from "../../components/DeleteConfirm";
interface IState {
deviceProfileTemplate?: DeviceProfileTemplate;
}
interface MatchParams {
deviceProfileTemplateId: string;
}
interface IProps extends RouteComponentProps<MatchParams> {}
class EditDeviceProfileTemplate extends Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {};
}
componentDidMount() {
this.getDeviceProfileTemplate();
}
getDeviceProfileTemplate = () => {
const id = this.props.match.params.deviceProfileTemplateId;
let req = new GetDeviceProfileTemplateRequest();
req.setId(id);
DeviceProfileTemplateStore.get(req, (resp: GetDeviceProfileTemplateResponse) => {
this.setState({
deviceProfileTemplate: resp.getDeviceProfileTemplate(),
});
});
};
onFinish = (obj: DeviceProfileTemplate) => {
let req = new UpdateDeviceProfileTemplateRequest();
req.setDeviceProfileTemplate(obj);
DeviceProfileTemplateStore.update(req, () => {
this.props.history.push(`/device-profile-templates`);
});
};
deleteDeviceProfileTemplate = () => {
let req = new DeleteDeviceProfileTemplateRequest();
req.setId(this.props.match.params.deviceProfileTemplateId);
DeviceProfileTemplateStore.delete(req, () => {
this.props.history.push(`/device-profile-templates`);
});
};
render() {
const dp = this.state.deviceProfileTemplate;
if (!dp) {
return null;
}
return (
<Space direction="vertical" style={{ width: "100%" }} size="large">
<PageHeader
breadcrumbRender={() => (
<Breadcrumb>
<Breadcrumb.Item>
<span>Network Server</span>
</Breadcrumb.Item>
<Breadcrumb.Item>
<span>
<Link to={`/device-profile-templates`}>Device-profile templates</Link>
</span>
</Breadcrumb.Item>
<Breadcrumb.Item>
<span>{dp.getName()}</span>
</Breadcrumb.Item>
</Breadcrumb>
)}
title={dp.getName()}
subTitle={`device-profile template id: ${dp.getId()}`}
extra={[
<DeleteConfirm
typ="device-profile template"
confirm={dp.getName()}
onConfirm={this.deleteDeviceProfileTemplate}
>
<Button danger type="primary">
Delete device-profile template
</Button>
</DeleteConfirm>,
]}
/>
<Card>
<DeviceProfileTemplateForm initialValues={dp} update={true} onFinish={this.onFinish} />
</Card>
</Space>
);
}
}
export default EditDeviceProfileTemplate;

View File

@ -0,0 +1,87 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import { Space, Breadcrumb, Button, PageHeader } from "antd";
import { ColumnsType } from "antd/es/table";
import {
ListDeviceProfileTemplatesRequest,
ListDeviceProfileTemplatesResponse,
DeviceProfileTemplateListItem,
} from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_template_pb";
import { Region } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
import { getEnumName } from "../helpers";
import DataTable, { GetPageCallbackFunc } from "../../components/DataTable";
import DeviceProfileTemplateStore from "../../stores/DeviceProfileTemplateStore";
class ListDeviceProfileTemplates extends Component {
columns = (): ColumnsType<DeviceProfileTemplateListItem.AsObject> => {
return [
{
title: "Vendor",
dataIndex: "vendor",
key: "vendor",
},
{
title: "Name",
dataIndex: "name",
key: "name",
render: (text, record) => <Link to={`/device-profile-templates/${record.id}/edit`}>{text}</Link>,
},
{
title: "Firmware",
dataIndex: "firmware",
key: "firmware",
},
{
title: "Region",
dataIndex: "region",
key: "region",
width: 150,
render: (text, record) => {
return getEnumName(Region, record.region);
},
},
];
};
getPage = (limit: number, offset: number, callbackFunc: GetPageCallbackFunc) => {
let req = new ListDeviceProfileTemplatesRequest();
req.setLimit(limit);
req.setOffset(offset);
DeviceProfileTemplateStore.list(req, (resp: ListDeviceProfileTemplatesResponse) => {
const obj = resp.toObject();
callbackFunc(obj.totalCount, obj.resultList);
});
};
render() {
return (
<Space direction="vertical" style={{ width: "100%" }} size="large">
<PageHeader
breadcrumbRender={() => (
<Breadcrumb>
<Breadcrumb.Item>
<span>Network Server</span>
</Breadcrumb.Item>
<Breadcrumb.Item>
<span>Device-profile templates</span>
</Breadcrumb.Item>
</Breadcrumb>
)}
title="Device-profile templates"
extra={[
<Button type="primary">
<Link to={`/device-profile-templates/create`}>Add device-profile template</Link>
</Button>,
]}
/>
<DataTable columns={this.columns()} getPage={this.getPage} rowKey="id" />
</Space>
);
}
}
export default ListDeviceProfileTemplates;

View File

@ -43,7 +43,7 @@ class CreateDeviceProfile extends Component<IProps> {
// - data = Object representing the decoded payload.
function decodeUplink(input) {
return {
object: {
data: {
temp: 22.5
}
};

View File

@ -1,14 +1,184 @@
import React, { Component } from "react";
import { Form, Input, Select, InputNumber, Switch, Row, Col, Button, Tabs } from "antd";
import { Form, Input, Select, InputNumber, Switch, Row, Col, Button, Tabs, Modal, Spin, Cascader } from "antd";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { DeviceProfile, CodecRuntime } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import { Region, MacVersion, RegParamsRevision } from "@chirpstack/chirpstack-api-grpc-web/common/common_pb";
import { ListDeviceProfileAdrAlgorithmsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_pb";
import {
ListDeviceProfileTemplatesRequest,
ListDeviceProfileTemplatesResponse,
GetDeviceProfileTemplateRequest,
GetDeviceProfileTemplateResponse,
DeviceProfileTemplateListItem,
DeviceProfileTemplate,
} from "@chirpstack/chirpstack-api-grpc-web/api/device_profile_template_pb";
import { getEnumName } from "../helpers";
import DeviceProfileStore from "../../stores/DeviceProfileStore";
import DeviceProfileTemplateStore from "../../stores/DeviceProfileTemplateStore";
import CodeEditor from "../../components/CodeEditor";
interface ModalProps {
onOk: (dp: DeviceProfileTemplate) => void;
onCancel: () => void;
visible: boolean;
}
interface ModalState {
templates: DeviceProfileTemplateListItem[];
templatesLoaded: boolean;
templateId?: string;
}
interface Option {
value: string;
label: string;
children?: Option[];
}
class TemplateModal extends Component<ModalProps, ModalState> {
constructor(props: ModalProps) {
super(props);
this.state = {
templates: [],
templatesLoaded: false,
};
}
componentDidUpdate(prevProps: ModalProps) {
if (prevProps === this.props) {
return;
}
if (this.props.visible) {
this.setState({
templatesLoaded: false,
});
let req = new ListDeviceProfileTemplatesRequest();
req.setLimit(99999);
DeviceProfileTemplateStore.list(req, (resp: ListDeviceProfileTemplatesResponse) => {
this.setState({
templatesLoaded: true,
templates: resp.getResultList(),
});
});
}
}
onChange = (value: (string | number)[]) => {
this.setState({
templateId: value.at(-1)! as string,
});
};
onOk = () => {
if (this.state.templateId) {
let req = new GetDeviceProfileTemplateRequest();
req.setId(this.state.templateId);
DeviceProfileTemplateStore.get(req, (resp: GetDeviceProfileTemplateResponse) => {
const dp = resp.getDeviceProfileTemplate();
if (dp) {
this.props.onOk(dp);
}
});
}
};
render() {
let options: Option[] = [];
let vendor = "";
let device = "";
let firmware = "";
let region = "";
for (const item of this.state.templates) {
if (vendor !== item.getVendor()) {
options.push({
value: item.getId(),
label: item.getVendor(),
children: [],
});
vendor = item.getVendor();
device = "";
firmware = "";
region = "";
}
if (device !== item.getName()) {
options.at(-1)!.children!.push({
value: item.getId(),
label: item.getName(),
children: [],
});
device = item.getName();
firmware = "";
region = "";
}
if (firmware !== item.getFirmware()) {
options
.at(-1)!
.children!.at(-1)!
.children!.push({
value: item.getId(),
label: "FW version: " + item.getFirmware(),
children: [],
});
firmware = item.getFirmware();
region = "";
}
if (region !== getEnumName(Region, item.getRegion())) {
options
.at(-1)!
.children!.at(-1)!
.children!.at(-1)!
.children!.push({
value: item.getId(),
label: getEnumName(Region, item.getRegion()),
children: [],
});
region = getEnumName(Region, item.getRegion());
}
}
return (
<Modal
title="Select device-profile template"
visible={this.props.visible}
width="80%"
bodyStyle={{ height: 300 }}
onOk={this.onOk}
onCancel={this.props.onCancel}
okButtonProps={{ disabled: !!!this.state.templateId }}
>
{!this.state.templatesLoaded && (
<div className="spinner">
<Spin />
</div>
)}
{this.state.templatesLoaded && (
<Cascader
style={{ width: "100%" }}
placeholder="Select a device-profile template"
options={options}
onChange={this.onChange}
/>
)}
</Modal>
);
}
}
interface IProps {
initialValues: DeviceProfile;
onFinish: (obj: DeviceProfile) => void;
@ -21,6 +191,8 @@ interface IState {
supportsClassC: boolean;
payloadCodecRuntime: CodecRuntime;
adrAlgorithms: [string, string][];
templateModalVisible: boolean;
tabActive: string;
}
class DeviceProfileForm extends Component<IProps, IState> {
@ -34,6 +206,8 @@ class DeviceProfileForm extends Component<IProps, IState> {
supportsClassC: false,
payloadCodecRuntime: CodecRuntime.NONE,
adrAlgorithms: [],
templateModalVisible: false,
tabActive: "1",
};
}
@ -59,13 +233,21 @@ class DeviceProfileForm extends Component<IProps, IState> {
});
}
onTabChange = (activeKey: string) => {
this.setState({
tabActive: activeKey,
});
}
onFinish = (values: DeviceProfile.AsObject) => {
const v = Object.assign(this.props.initialValues.toObject(), values);
let dp = new DeviceProfile();
dp.setId(v.id);
dp.setTenantId(v.tenantId);
dp.setName(v.name);
dp.setDescription(v.description);
dp.setRegion(v.region);
dp.setMacVersion(v.macVersion);
dp.setRegParamsRevision(v.regParamsRevision);
@ -98,6 +280,7 @@ class DeviceProfileForm extends Component<IProps, IState> {
dp.getTagsMap().set(elm[0], elm[1]);
}
this.props.onFinish(dp);
};
@ -125,8 +308,102 @@ class DeviceProfileForm extends Component<IProps, IState> {
});
};
showTemplateModal = () => {
this.setState({
templateModalVisible: true,
});
};
onTemplateModalOk = (dp: DeviceProfileTemplate) => {
this.setState({
templateModalVisible: false,
});
this.formRef.current.setFieldsValue({
name: dp.getName(),
description: dp.getDescription(),
region: dp.getRegion(),
macVersion: dp.getMacVersion(),
regParamsRevision: dp.getRegParamsRevision(),
adrAlgorithmId: dp.getAdrAlgorithmId(),
payloadCodecRuntime: dp.getPayloadCodecRuntime(),
payloadCodecScript: dp.getPayloadCodecScript(),
flushQueueOnActivate: dp.getFlushQueueOnActivate(),
uplinkInterval: dp.getUplinkInterval(),
deviceStatusReqInterval: dp.getDeviceStatusReqInterval(),
supportsOtaa: dp.getSupportsOtaa(),
supportsClassB: dp.getSupportsClassB(),
supportsClassC: dp.getSupportsClassC(),
classBTimeout: dp.getClassBTimeout(),
abpRx1Delay: dp.getAbpRx1Delay(),
abpRx2Dr: dp.getAbpRx2Dr(),
abpRx2Freq: dp.getAbpRx2Freq(),
tagsMap: [
["firmware", dp.getFirmware()],
["vendor", dp.getVendor()],
["device", dp.getName()],
["device_profile_template_id", dp.getId()],
],
});
const tabActive = this.state.tabActive;
this.setState({
supportsOtaa: dp.getSupportsOtaa(),
supportsClassB: dp.getSupportsClassB(),
supportsClassC: dp.getSupportsClassC(),
payloadCodecRuntime: dp.getPayloadCodecRuntime(),
}, () => {
// This is a workaround as without rendering the TabPane (e.g. the user
// does not click through the different tabs), setFieldsValue does not
// actually update the fields. For example if selecting a template with
// a codec script and immediately click the save button, no codec script
// is passed to the onFinish function. This seems to be with every field
// that is not actually rendered before clicking the Save button.
this.setState({
tabActive: "1",
}, () => {
this.setState({
tabActive: "2",
}, () => {
this.setState({
tabActive: "3",
}, () => {
this.setState({
tabActive: "4",
}, () => {
this.setState({
tabActive: "5",
}, () => {
this.setState({
tabActive: "6",
}, () => {
this.setState({
tabActive: tabActive,
});
});
});
});
});
});
});
});
};
onTemplateModalCancel = () => {
this.setState({
templateModalVisible: false,
});
};
render() {
const adrOptions = this.state.adrAlgorithms.map(v => <Select.Option value={v[0]}>{v[1]}</Select.Option>);
const operations = (
<Button type="primary" onClick={this.showTemplateModal}>
Select device-profile template
</Button>
);
return (
<Form
@ -135,11 +412,19 @@ class DeviceProfileForm extends Component<IProps, IState> {
onFinish={this.onFinish}
ref={this.formRef}
>
<Tabs>
<TemplateModal
visible={this.state.templateModalVisible}
onOk={this.onTemplateModalOk}
onCancel={this.onTemplateModalCancel}
/>
<Tabs tabBarExtraContent={operations} activeKey={this.state.tabActive} onChange={this.onTabChange}>
<Tabs.TabPane tab="General" key="1">
<Form.Item label="Name" name="name" rules={[{ required: true, message: "Please enter a name!" }]}>
<Input disabled={this.props.disabled} />
</Form.Item>
<Form.Item label="Description" name="description">
<Input.TextArea rows={6} disabled={this.props.disabled} />
</Form.Item>
<Form.Item label="Region" name="region" rules={[{ required: true, message: "Please select a region!" }]}>
<Select disabled={this.props.disabled}>
<Select.Option value={Region.AS923}>AS923</Select.Option>
@ -330,7 +615,7 @@ class DeviceProfileForm extends Component<IProps, IState> {
<CodeEditor
label="Codec functions"
name="payloadCodecScript"
value={this.props.initialValues.getPayloadCodecScript()}
value={this.formRef.current.getFieldValue("payloadCodecScript")}
formRef={this.formRef}
disabled={this.props.disabled}
/>

View File

@ -100,7 +100,7 @@ class ListTenants extends Component {
breadcrumbRender={() => (
<Breadcrumb>
<Breadcrumb.Item>
<span>Network-server</span>
<span>Network Server</span>
</Breadcrumb.Item>
<Breadcrumb.Item>
<span>Tenants</span>

View File

@ -67,7 +67,7 @@ class ListUsers extends Component {
breadcrumbRender={() => (
<Breadcrumb>
<Breadcrumb.Item>
<span>Network-server</span>
<span>Network Server</span>
</Breadcrumb.Item>
<Breadcrumb.Item>
<span>Users</span>

View File

@ -2839,7 +2839,7 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==
"@types/qs@*":
"@types/qs@*", "@types/qs@^6.9.7":
version "6.9.7"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
@ -3369,16 +3369,17 @@ antd-mask-input@^2.0.7:
dependencies:
imask "6.4.2"
antd@^4.20.1:
version "4.20.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-4.20.1.tgz#6cd5a406c7172d61a5d0693ea52ee908650cf674"
integrity sha512-asKxOV0a6AijqonbcXkO08/q+XvqS/HmGfaRIS6ZH1ALR3FS2q+kTW52rJZO9rfoOb/ldPhEBVSWiNrbiB+uCQ==
antd@^4.20.6:
version "4.20.6"
resolved "https://registry.yarnpkg.com/antd/-/antd-4.20.6.tgz#0d46a4b6128a717b4cad7ac6902311a0210f1d6f"
integrity sha512-JHEwCDjWTAJ1yxlC5QPb7LhRMvdhccN5lzMYDs72sp6VOiaXVGAlwols+F8nQQRaF9h/eA6yQyZ622y8b9vaoQ==
dependencies:
"@ant-design/colors" "^6.0.0"
"@ant-design/icons" "^4.7.0"
"@ant-design/react-slick" "~0.28.1"
"@babel/runtime" "^7.12.5"
"@ctrl/tinycolor" "^3.4.0"
"@types/qs" "^6.9.7"
classnames "^2.2.6"
copy-to-clipboard "^3.2.0"
lodash "^4.17.21"
@ -3403,7 +3404,7 @@ antd@^4.20.1:
rc-progress "~3.2.1"
rc-rate "~2.9.0"
rc-resize-observer "^1.2.0"
rc-segmented "~2.0.0"
rc-segmented "~2.1.0 "
rc-select "~14.1.1"
rc-slider "~10.0.0"
rc-steps "~4.1.0"
@ -8846,10 +8847,10 @@ rc-resize-observer@^1.1.0, rc-resize-observer@^1.2.0:
rc-util "^5.15.0"
resize-observer-polyfill "^1.5.1"
rc-segmented@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/rc-segmented/-/rc-segmented-2.0.0.tgz#209b55bec85c1a8b1821c30e62d3ebef4da04b52"
integrity sha512-YsdS+aP7E6ZMEY35WSlewJIsrjPbBSP4X/7RvZtzLExKDZwFvXdCPCbWFVDNks4jOYY9TUPYt7qlVifEu9/zXA==
"rc-segmented@~2.1.0 ":
version "2.1.0"
resolved "https://registry.yarnpkg.com/rc-segmented/-/rc-segmented-2.1.0.tgz#0e0afe646c1a0e44a0e18785f518c42633ec8efc"
integrity sha512-hUlonro+pYoZcwrH6Vm56B2ftLfQh046hrwif/VwLIw1j3zGt52p5mREBwmeVzXnSwgnagpOpfafspzs1asjGw==
dependencies:
"@babel/runtime" "^7.11.1"
classnames "^2.2.1"