Prerequisites

Create a panel

In the Codelab, you can leverage the capabilities of the panel miniapp and Three.js to develop and build a smart fan panel that can display a specific 3D model.

Learning objectives

Prerequisites

For more information, see Panel MiniApp > Set up environment.

Product name: smart fan

Requirement prototype

A product defines the data points (DPs) of the associated panel and device. Before you develop a panel, you must create a product, define the required DPs, and then implement these DPs on the panel.

Register and log in to the Tuya Developer Platform and create a product.

  1. In the left-side navigation pane, choose Product > Development > Create.
  2. In the Standard Category tab, choose Small Home Appliances > Fans > Fan.
  3. Select a smart mode and solution, and complete product information. For example, specify Product Name as Fan.
  4. Click Create.
  5. After your product is created, the Add Standard Function dialog box appears. You can choose from the available standard functions. The video preview feature is still supported if these standard functions are not added.

🎉 Now, you have created a smart fan product named Fan.

Create panel miniapp on Smart MiniApp Developer Platform

Register and log in to the Smart MiniApp Developer Platform. For more information, see Create panel miniapp.

Pull and run template project

Go to the panel template repository.

  1. Pull the miniapp project.
    git clone https://github.com/Tuya-Community/tuya-ray-materials.git
    
  2. Open the panel-d3 template, use the integrated development environment (IDE) to import the source code directory, and then link the project with the created miniapp.
    cd ./examples/panel-d3
    
  3. Import the project into the IDE, and link the project with the created panel miniapp and product.

Model loading

  1. Configure development environment.Due to certain limitations of the miniapp environment, in order to use 3D features, you need to import and configure three.js in the project and use Render Script (RJS) for development.For more information, see the configuration documentation.
  2. Load the 3D model.Support .gltf and .glb model formats. You can upload the model to the CDN first, and then load it via the CDN.
    // The model URL can be in .glb or .gltf format and needs to be an online address. You can upload it via CDN.
    // Domain and URI, such as https://xxx.xxx.com/smart/miniapp/static/bay1591069675815HNkH/17109234773d561a0f716.gltf
    const modelUrl = useRef(
      `${domain}/smart/miniapp/static/bay1591069675815HNkH/17109234773d561a0f716.gltf`
    ).current;
    
  3. Import D3-Component.
    import React, { FC, useCallback, useEffect, useMemo, useRef } from "react";
    import { useSelector } from "react-redux";
    import { View } from "@ray-js/components";
    import { selectDpStateByCode } from "@/redux/modules/dpStateSlice";
    import { fanSpeedCode, switchCode } from "@/config/dpCodes";
    import {
      ComponentView,
      ComponentApi,
      ComponentConst,
    } from "@/components/D3Component";
    
    import styles from "./index.module.less";
    import Temperature from "./Temperature";
    import CountdownTips from "./CountdownTips";
    
    type Props = {
      disabledAnimation?: boolean;
    };
    
    const Fan: FC<Props> = ({ disabledAnimation = false }) => {
      const componentId = useRef(`component_${new Date().getTime()}`).current;
      const isAppOnBackground = useRef(false);
      // The model URL can be in .glb or .gltf format and needs to be an online address. You can upload it via CDN.
      // Domain and URI, such as https://xxx.xxx.com/smart/miniapp/static/bay1591069675815HNkH/17109234773d561a0f716.gltf
      const modelUrl = useRef(
        `${domain}/smart/miniapp/static/bay1591069675815HNkH/17109234773d561a0f716.gltf`
      ).current;
      const dpSwitch = useSelector(selectDpStateByCode(switchCode));
      const dpFanSpeed = useSelector(selectDpStateByCode(fanSpeedCode));
    
      const animationEnable = !disabledAnimation && dpSwitch;
      useEffect(() => {
        if (!animationEnable) {
          ComponentApi.setFanAnimation(componentId, {
            fanSpeed: ComponentConst.FAN_LEVEL.stop,
          });
        }
      }, [animationEnable]);
    
      useEffect(() => {
        updateFanSpeed();
      }, [dpFanSpeed, dpSwitch]);
    
      const updateFanSpeed = useCallback(() => {
        if (dpSwitch) {
          if (dpFanSpeed < 33) {
            ComponentApi.setFanAnimation(componentId, {
              fanSpeed: ComponentConst.FAN_LEVEL.low,
            });
          } else if (dpFanSpeed > 33 && dpFanSpeed < 66) {
            ComponentApi.setFanAnimation(componentId, {
              fanSpeed: ComponentConst.FAN_LEVEL.mid,
            });
          } else if (dpFanSpeed > 66 && dpFanSpeed < 80) {
            ComponentApi.setFanAnimation(componentId, {
              fanSpeed: ComponentConst.FAN_LEVEL.hight,
            });
          } else if (dpFanSpeed > 80) {
            ComponentApi.setFanAnimation(componentId, {
              fanSpeed: ComponentConst.FAN_LEVEL.max,
            });
          }
        } else {
          ComponentApi.setFanAnimation(componentId, {
            fanSpeed: ComponentConst.FAN_LEVEL.stop,
          });
        }
      }, [dpFanSpeed, dpSwitch]);
    
      const onComponentLoadEnd = useCallback(
        (data: { infoKey: string; progress: number }) => {
          if (data.progress === 100) {
            ComponentApi.startAnimationFrame(componentId, { start: true });
            updateFanSpeed();
          }
        },
        []
      );
    
      const onGestureChange = useCallback((start: boolean) => {
        if (start) {
          console.log("onGestureChange", start);
        }
      }, []);
    
      const onResetControl = useCallback((success: boolean) => {
        console.log("onResetControl", success);
      }, []);
    
      /**
       * Disconnect specific animation rendering when the miniapp is running in the background
       */
      const onEnterBackground = () => {
        ty.onAppHide(() => {
          isAppOnBackground.current = true;
          // Stop rendering the entire scene
          ComponentApi.startAnimationFrame(componentId, { start: false });
        });
      };
    
      /**
       * Enable specific animation rendering when the miniapp is running in the foreground
       */
      const onEnterForeground = () => {
        ty.onAppShow(() => {
          ComponentApi.startAnimationFrame(componentId, { start: true });
          isAppOnBackground.current = false;
        });
      };
    
      /**
       * Unload the entire component
       */
      const unmount = () => {
        ComponentApi.setFanAnimation(componentId, {
          fanSpeed: ComponentConst.FAN_LEVEL.stop,
        });
        ComponentApi.disposeComponent(componentId);
      };
    
      useEffect(() => {
        onEnterBackground();
        onEnterForeground();
        return () => {
          unmount();
        };
      }, []);
    
      return (
        <View className={styles.container}>
          <View className={styles.view}>
            <ComponentView
              eventProps={{
                onComponentLoadEnd,
                onGestureChange,
                onResetControl,
              }}
              componentId={componentId}
              modelUrl={modelUrl}
            />
          </View>
          <Temperature />
          <CountdownTips />
        </View>
      );
    };
    
    export default Fan;
    

ComponentApi

ComponentApi encapsulates an API function that can communicate with 3D components from the business layer. By referring to this design, you can customize how to send commands to the 3D model from the business layer. You can build capabilities based on your requirements.

For example, refer to the following steps to set whether to turn on the fan in this template.

  1. First, define a function in ComponentApi and send it to the view layer through event distribution.The sample file is src/components/D3Component/api/index.ts.
    /**
     * Control whether the fan is running and set its speed
     * @param componentId
     * @param opts fanSpeed: FAN_LEVEL
     * @param cb
     * @returns
     */
    const setFanAnimation = (
      componentId: string,
      opts: { fanSpeed: number },
      cb?: (data: IEventData) => void
    ) => {
      NotificationCenter.pushNotification(`${componentId}${propsEventCallee}`, {
        method: "setFanAnimation",
        componentId,
        calleeProps: {
          timestamp: new Date().getTime(),
          ...opts,
        },
      });
      if (typeof cb !== "function") {
        return new Promise((resolve, reject) => {
          onLaserApiCallback(
            `${componentId}_setFanAnimation`,
            (eventData: IEventData) => normalResolve(eventData, resolve, reject)
          );
        });
      }
    
      onLaserApiCallback(`${componentId}_setFanAnimation`, cb);
      return null;
    };
    
  2. Then, define a method to receive this event in the miniapp component JS and distribute it to the RJS view layer by using deconstructApiProps.The sample file is /src/components/D3Component/components/rjs-component/index.js.
    /**
     * @language EN
     * @description Miniapp component
     */
    import { actions, store } from "../../redux";
    import Render from "./index.rjs";
    import { NotificationCenter } from "../../notification";
    import { propsEventCallee } from "../../api";
    
    const componentOptions = {
      options: {
        pureDataPattern: /^isInitial$/,
      },
      data: {
        isInitial: true,
      },
      properties: {
        componentId: String,
        modelUrl: String,
      },
    
      lifetimes: {
        created() {
          this.instanceType = "rjs";
          this.render = new Render(this);
        },
        ready() {
          // Register an API method to call events
          this.registerApiCallee();
          if (this.render) {
            this.render.initComponent(this.data.componentId, this.data.modelUrl);
            this.storeComponent();
            this.setData({ isInitial: false });
          }
        },
        detached() {
          this.clearComponent();
          this.removeComponentApiCallee();
          this.render = null;
        },
      },
      pageLifetimes: {},
      methods: {
        storeComponent() {
          store.dispatch(
            actions.component.initComponent({
              componentId: this.data.componentId,
              instanceType: this.instanceType,
            })
          );
        },
        clearComponent() {
          this.render.disposeComponent();
          store.dispatch(
            actions.component.willUnmount({ componentId: this.data.componentId })
          );
        },
        registerApiCallee() {
          if (!this.data.componentId) {
            return;
          }
          NotificationCenter.addEventListener(
            `${this.data.componentId}${propsEventCallee}`,
            (data) => {
              if (data && data.method) {
                if (this.render) {
                  this.render.deconstructApiProps(data);
                }
              }
            }
          );
        },
        removeComponentApiCallee() {
          NotificationCenter.removeEventListener(
            `${this.data.componentId}${propsEventCallee}`
          );
        },
        onComponentApiCallee(data) {
          if (data && data.componentId && data.method) {
            const results = {
              componentId: data.componentId,
              results: data,
            };
            NotificationCenter.pushNotification(
              `${data.componentId}_${data.method}`,
              results
            );
          }
        },
        /**
         * @language EN
         * @description The following methods are used to bind the communication between the native components of the miniapp and the Ray components
         * @param {*} data
         */
        onComponentLoadEnd(data) {
          this.triggerEvent("oncomponentloadend", data);
        },
        onGestureChange(data) {
          this.triggerEvent("ongesturechange", data);
        },
        onResetControl(data) {
          this.triggerEvent("onresetcontrol", data);
        },
      },
    };
    
    Component(componentOptions);
    
  3. Finally, define the specific function to execute this method in RJS, thereby implementing how to trigger the 3D module of the view layer map from the business layer.The sample file is /src/components/D3Component/components/rjs-component/index.rjs.
    import _ from "lodash";
    import { KTInstance } from "../../lib/KTInstance";
    import { getLocalUrl } from "../../lib/utils/Functions.ts";
    import { ELECTRIC_FAN } from "../../lib/models";
    
    export default Render({
      _onComponentLoadEnd(data) {
        this.callMethod("onComponentLoadEnd", data);
      },
    
      _onGestureChange(data) {
        this.callMethod("onGestureChange", data);
      },
    
      _onResetControl(data) {
        this.callMethod("onResetControl", data);
      },
    
      _onComponentApiCallee(data) {
        this.callMethod("onComponentApiCallee", data);
      },
    
      // Send a callback of the API execution results to the business layer through _onComponentApiCallee
      _resolveSuccess(methodName, results) {
        const timeEnd = new Date().getTime();
        const timeStart = results.props.timestamp;
        this._onComponentApiCallee({
          method: methodName,
          componentId: this.componentId,
          status: true,
          communicationTime: timeEnd - timeStart,
          timestamp: timeEnd,
          props: results.props,
          values: results.values,
        });
      },
    
      // Send a callback of the API execution results to the business layer through _onComponentApiCallee
      _resolveFailed(methodName, results) {
        const timeEnd = new Date().getTime();
        const timeStart = results.props.timestamp;
        this._onComponentApiCallee({
          method: methodName,
          componentId: this.componentId,
          status: false,
          communicationTime: timeEnd - timeStart,
          timestamp: timeEnd,
          props: results.props,
          values: results.values,
        });
      },
    
      /** initComponent
       * Initialization
       * @param sysInfo
       * @param newValue
       */
      async initComponent(componentId, pixelRatio, modelUrl) {
        try {
          const canvas = await getCanvasById(componentId);
          const THREE = await requirePlugin("rjs://three").catch((err) => {
            console.log("usePlugins occur an error", err);
          });
          const { GLTFLoader } = await requirePlugin(
            "rjs://three/examples/jsm/loaders/GLTFLoader"
          ).catch((err) => {
            console.log("usePlugins occur an error", err);
          });
          this._GLTFLoader = GLTFLoader;
          this.componentId = componentId;
    
          await this.instanceDidMount(canvas, pixelRatio, modelUrl);
        } catch (e) {
          console.log("initComponent occur an error", e);
        }
      },
    
      async instanceDidMount(canvas, pixelRatio, modelUrl) {
        this.fanModelList = [];
        const params = {
          canvas: canvas,
          antialias: true,
          pixelRatio: pixelRatio,
          logarithmicDepthBuffer: true,
          preserveDrawingBuffer: true,
          onComponentLoadEnd: this._onComponentLoadEnd,
          onGestureChange: this._onGestureChange,
          onResetControl: this._onResetControl,
          onClickInfoWindow: this._onClickInfoWindow,
          GLTFLoader: this._GLTFLoader,
        };
        this.kTInstance = new KTInstance(params);
        // Here is a fallback model
        const url = modelUrl || getLocalUrl(ELECTRIC_FAN);
        const model = await this.kTInstance.loadModelLayer({
          infoKey: "model",
          uri: url,
        });
    
        if (model) {
          this.layer = model.scene;
          this.animations = model.animations;
          // Prepare a key frame
          this.animationMixer = this.kTInstance.kTComponent.createAnimationMixer(
            this.layer
          );
          this.clipAnimation = this.animations.map((item) => {
            return this.animationMixer.clipAction(item);
          });
    
          // Adapt to different models
          this.layer.scale.set(0.4, 0.4, 0.4);
          this.kTInstance.putLayerIntoScene(this.layer);
          this.setFanModel();
        }
      },
    
      /**
       * Deconstruct the specific APIs
       * @param opts
       */
      deconstructApiProps(opts) {
        if (typeof this[opts.method] === "function") {
          this[opts.method](opts.calleeProps);
        }
      },
    
      /**
       * Find specific fan blades from the model
       */
      setFanModel() {
        this.fanModel = this.kTInstance.getChildByNameFromObject(
          this.layer,
          "Group080"
        )[0];
      },
    
      /**
       * Start fan running animation
       * @param speed
       */
      startFanAnimate(fanSpeed) {
        this.stopFanAnimate();
        this.fanAnimationIdList = [this.fanModel].map((item) =>
          this.kTInstance.startRotateAnimationOnZ(item, fanSpeed)
        );
      },
    
      /**
       * Stop fan running animation
       */
      stopFanAnimate() {
        if (this.fanAnimationIdList) {
          this.fanAnimationIdList.map((item) =>
            this.kTInstance.stopAnimation(item)
          );
        }
      },
    
      /**
       * Enable animation rendering
       * @param start
       */
      startAnimationFrameVision(start) {
        if (this.kTInstance.kTComponent) {
          this.kTInstance.kTComponent.animateStatus = start;
        }
      },
      /**
       * Control whether the fan is running and set its speed
       * @param opts
       */
      setFanAnimation(opts) {
        const methodName = "setFanAnimation";
        if (opts) {
          const results = {
            props: opts,
            values: {},
          };
          const { fanSpeed = 0.1 } = opts;
          if (this.kTInstance) {
            if (fanSpeed !== 0) {
              this.startFanAnimate(fanSpeed);
            } else {
              this.stopFanAnimate();
            }
            this._resolveSuccess(methodName, results);
          } else {
            this._resolveFailed(methodName, results);
          }
        }
      },
    
      /**
       * Enable scene rendering
       * @param opts
       */
      startAnimationFrame(opts) {
        const methodName = "startAnimationFrame";
        if (opts) {
          const results = {
            props: opts,
            values: {},
          };
          const { start } = opts;
          if (this.kTInstance) {
            this.startAnimationFrameVision(start);
            this._resolveSuccess(methodName, results);
          } else {
            this._resolveFailed(methodName, results);
          }
        }
      },
    
      /**
       * Dispose the 3D component
       * @param opts
       */
      disposeComponent() {
        const methodName = "disposeComponent";
        const results = {
          props: {},
          values: {},
        };
        if (this.kTInstance) {
          this.kTInstance.kTComponent.disposeComponent();
          this._resolveSuccess(methodName, results);
        } else {
          this._resolveFailed(methodName, results);
        }
      },
    });
    

Callbacks

After a model event is triggered within the 3D model, the Callbacks module sends a callback of event results to the business layer.

  1. First, use the bind method on the component to bind a specific event.The sample file is /src/components/D3Component/view/index.tsx.
    import React from "react";
    import { View } from "@ray-js/ray";
    import RjsView from "../components/rjs-component";
    import { IProps, defaultProps } from "./props";
    import { getRayRealCallbacksName } from "../callbacks";
    import styles from "./index.module.less";
    
    const Component: React.FC<IProps> = (props: IProps) => {
      const {
        componentId = `component_${new Date().getTime()}`,
        modelUrl,
        eventProps,
      } = props;
      /**
       * Trigger the specified event
       */
      const onEvent = (evt: { type: string; detail: any }) => {
        const { detail, type } = evt;
        eventProps[getRayRealCallbacksName(type)]?.(detail);
      };
      return (
        <View className={styles.view}>
          <RjsView
            componentId={componentId}
            modelUrl={modelUrl}
            bindoncomponentloadend={onEvent}
            bindongesturechange={onEvent}
            bindonresetcontrol={onEvent}
          />
        </View>
      );
    };
    
    Component.defaultProps = defaultProps;
    Component.displayName = "Component";
    
    export default Component;
    
  2. Then in the miniapp component, define triggerEvent that triggers the event.The sample file is /src/components/D3Component/components/rjs-component/index.js.
    /**
     * @language EN
     * @description The following methods are used to bind the communication between the native components of the miniapp and the Ray components
     * @param {*} data
     */
    onComponentLoadEnd(data) {
    this.triggerEvent('oncomponentloadend', data);
    },
    onGestureChange(data) {
    this.triggerEvent('ongesturechange', data);
    },
    onResetControl(data) {
    this.triggerEvent('onresetcontrol', data);
    },
    
  3. Finally, define the actual trigger function in the view layer.The sample file is /src/components/D3Component/components/rjs-component/index.rjs.
    export default Render({
      _onComponentLoadEnd(data) {
        this.callMethod("onComponentLoadEnd", data);
      },
    
      _onGestureChange(data) {
        this.callMethod("onGestureChange", data);
      },
    
      _onResetControl(data) {
        this.callMethod("onResetControl", data);
      },
    
      _onComponentApiCallee(data) {
        this.callMethod("onComponentApiCallee", data);
      },
    });
    

Control on/off status and wind speed

To control the fan's on/off status and wind speed, you must get and send specific DP values ​​based on the DP status and definition.

If you need to turn on the fan, follow the steps below to get the specific on/off status from Hooks and send your corresponding data:

import React, { FC, useState } from "react";
import clsx from "clsx";
import { Text, View } from "@ray-js/components";
import { useDispatch, useSelector } from "react-redux";
import { selectDpStateByCode, updateDp } from "@/redux/modules/dpStateSlice";
import { useThrottleFn } from "ahooks";
import { TouchableOpacity } from "@/components";
import Strings from "@/i18n";
import styles from "./index.module.less";

type Props = {};
const Control: FC<Props> = () => {
  const dispatch = useDispatch();
  // Get the current on/off status
  const dpSwitch = useSelector(selectDpStateByCode(switchCode));

  const [panelVisible, setPanelVisible] = useState(false);

  // Bind button click events and use throttling functions to throttle
  const handleSwitch = useThrottleFn(
    () => {
      setPanelVisible(false);
      // Update the DP status
      dispatch(updateDp({ [switchCode]: !dpSwitch }));
      ty.vibrateShort({ type: "light" });
    },
    { wait: 600, trailing: false }
  ).run;

  return (
    <View className={styles.container}>
      <TouchableOpacity
        className={styles.item}
        activeOpacity={1}
        onClick={handleSwitch}
      >
        <View
          className={clsx(
            styles.controlButton,
            styles.controlButtonSwitch,
            dpSwitch && "active"
          )}
        >
          <Text
            className="iconfontpanel icon-panel-power"
            style={{ color: "#fff" }}
          />
        </View>
        <Text className={styles.itemText}>{Strings.getLang("dsc_switch")}</Text>
      </TouchableOpacity>
    </View>
  );
};

Timer

You can use the timer API to schedule a device to execute specific logic at the appropriate time, such as turning a fan on or off. Generally speaking, the timer involves four main features: getting a list of timers, adding, modifying, and deleting a timer.

The following APIs are included:

// Get a list of timers.
export const fetchTimingsApi = async (
  category = DEFAULT_TIMING_CATEGORY,
  isGroup = false
) => {
  try {
    const response = await apiRequest<IQueryTimerTasksResponse>({
      api: "m.clock.dps.list",
      version: "1.0",
      data: {
        bizType: isGroup ? "1" : "0",
        bizId: getDevId(),
        category,
      },
    });
    return response;
  } catch (err) {
    return Promise.reject(err);
  }
};
// Add a timer
export const addTimingApi = async (params: IAndSingleTime) => {
  try {
    const response = await apiRequest<EntityId>({
      api: "m.clock.dps.add",
      version: "1.0",
      data: params,
    });
    return response;
  } catch (err) {
    return Promise.reject(err);
  }
};
// Update the specified timer
export const updateTimingApi = async (params: IModifySingleTimer) => {
  try {
    const response = await apiRequest<boolean>({
      api: "m.clock.dps.update",
      version: "1.0",
      data: params,
    });
    return response;
  } catch (err) {
    return Promise.reject(err);
  }
};
// Delete the specified timer
export const updateStatusOrDeleteTimingApi = async (param: {
  ids: string;
  status: 0 | 1 | 2;
}) => {
  const { groupId: devGroupId, devId } = getDevInfo();
  const defaultParams = {
    bizType: devGroupId ? "1" : "0",
    bizId: devId,
  };
  try {
    const response = await apiRequest<boolean>({
      api: "m.clock.batch.status.update",
      version: "1.0",
      data: { ...defaultParams, ...param },
    });
    return response;
  } catch (err) {
    return Promise.reject(err);
  }
};

Typically, you can combine the timer API with Redux to update API data. For more information, see timingSlice:

import {
  addTimingApi,
  fetchTimingsApi,
  updateStatusOrDeleteTimingApi,
  updateTimingApi,
} from "@/api";
import {
  createAsyncThunk,
  createEntityAdapter,
  createSlice,
  EntityId,
} from "@reduxjs/toolkit";
import { DEFAULT_TIMING_CATEGORY } from "@/constant";
import moment from "moment";
import { ReduxState } from "..";
import { kit } from "@ray-js/panel-sdk";

const { getDevInfo } = kit;

type Timer = IAndSingleTime & {
  time: string;
  id: EntityId;
};

type AddTimerPayload = {
  dps: string;
  time: string;
  loops: string;
  actions: any;
  aliasName?: string;
};

const timingsAdapter = createEntityAdapter<Timer>({
  sortComparer: (a, b) =>
    moment(a.time, "HH:mm").isBefore(moment(b.time, "HH:mm")) ? -1 : 1,
});

export const fetchTimings = createAsyncThunk<Timer[]>(
  "timings/fetchTimings",
  async () => {
    const { timers } = await fetchTimingsApi();

    return timers as unknown as Timer[];
  }
);

export const addTiming = createAsyncThunk<Timer, AddTimerPayload>(
  "timings/addTiming",
  async (param) => {
    const { groupId: devGroupId, devId } = getDevInfo();
    const defaultParams = {
      bizId: devGroupId || devId,
      bizType: devGroupId ? "1" : "0",
      isAppPush: false,
      category: DEFAULT_TIMING_CATEGORY,
    };
    const params = { ...defaultParams, ...param };
    const id = await addTimingApi(params);
    return { id, status: 1, ...params };
  }
);

export const updateTiming = createAsyncThunk(
  "timings/updateTiming",
  async (param: AddTimerPayload & { id: EntityId }) => {
    const { groupId: devGroupId, devId } = getDevInfo();
    const defaultParams = {
      bizId: devGroupId || devId,
      bizType: devGroupId ? "1" : "0",
      isAppPush: false,
      category: DEFAULT_TIMING_CATEGORY,
    };
    const params = { ...defaultParams, ...param };
    await updateTimingApi(params);
    return { id: param.id, changes: param };
  }
);

export const deleteTiming = createAsyncThunk<EntityId, EntityId>(
  "timings/deleteTiming",
  async (id) => {
    // Status 2 --- delete
    await updateStatusOrDeleteTimingApi({ ids: String(id), status: 2 });
    return id;
  }
);

export const updateTimingStatus = createAsyncThunk(
  "timings/updateTimingStatus",
  async ({ id, status }: { id: EntityId; status: 0 | 1 }) => {
    // status 0: turn off. 1: turn on.
    await updateStatusOrDeleteTimingApi({ ids: String(id), status });
    return { id, changes: { status: status ?? 0 } };
  }
);

/**
 * Slice
 */
const timingsSlice = createSlice({
  name: "timings",
  initialState: timingsAdapter.getInitialState(),
  reducers: {},
  extraReducers(builder) {
    builder.addCase(fetchTimings.fulfilled, (state, action) => {
      timingsAdapter.upsertMany(state, action.payload);
    });
    builder.addCase(addTiming.fulfilled, (state, action) => {
      timingsAdapter.upsertOne(state, action.payload);
    });
    builder.addCase(deleteTiming.fulfilled, (state, action) => {
      timingsAdapter.removeOne(state, action.payload);
    });
    builder.addCase(updateTimingStatus.fulfilled, (state, action) => {
      timingsAdapter.updateOne(state, action.payload);
    });
    builder.addCase(updateTiming.fulfilled, (state, action) => {
      timingsAdapter.updateOne(state, action.payload);
    });
  },
});

/**
 * Selectors
 */
const selectors = timingsAdapter.getSelectors(
  (state: ReduxState) => state.timings
);
export const {
  selectIds: selectAllTimingIds,
  selectAll: selectAllTimings,
  selectTotal: selectTimingsTotal,
  selectById: selectTimingById,
  selectEntities: selectTimingEntities,
} = selectors;

export default timingsSlice.reducer;

You just need to follow similar operations as DP to get and update Redux, and interact with the cloud APIs.

For example, add a timer:

import React, { FC, useMemo, useState } from "react";
import clsx from "clsx";
import { EntityId } from "@reduxjs/toolkit";
import {
  PageContainer,
  ScrollView,
  Switch,
  Text,
  View,
} from "@ray-js/components";
import TimePicker from "@ray-js/components-ty-time-picker";
import { useSelector } from "react-redux";
import { DialogInput, TouchableOpacity, WeekSelector } from "@/components";
import Strings from "@/i18n";
import { WEEKS } from "@/constant";
import { checkDpExist, getDpIdByCode } from "@/utils";
import { lightCode, switchCode } from "@/config/dpCodes";
import {
  addTiming,
  selectTimingById,
  updateTiming,
} from "@/redux/modules/timingsSlice";
import { ReduxState, useAppDispatch } from "@/redux";

import styles from "./index.module.less";

type Props = {
  visible: boolean;
  onClose: () => void;
  id?: EntityId;
};

const TimingAdd: FC<Props> = ({ id, visible, onClose }) => {
  const dispatch = useAppDispatch();
  const { language } = useMemo(() => ty.getSystemInfoSync(), []);

  // The initial value when editing
  const currentTiming = useSelector((state: ReduxState) =>
    id ? selectTimingById(state, id) : null
  );

  const [timeState, setTimeState] = useState(() => {
    if (currentTiming) {
      const [h, m] = currentTiming?.time.split(":");
      return {
        hour: Number(h),
        minute: Number(m),
      };
    }

    return {
      hour: new Date().getHours(),
      minute: new Date().getMinutes(),
    };
  });

  const dpsObject = useMemo(() => {
    return currentTiming?.dps ? JSON.parse(currentTiming.dps) : {};
  }, [currentTiming]);

  const [loops, setLoops] = useState(
    (currentTiming?.loops ?? "0000000").split("")
  );
  const [dialogVisible, setDialogVisible] = useState(false);
  const [remark, setRemark] = useState(currentTiming?.aliasName ?? "");
  const [fanSwitch, setFanSwitch] = useState(
    () => dpsObject?.[getDpIdByCode(switchCode)] ?? false
  );
  const [lightSwitch, setLightSwitch] = useState(
    () => dpsObject?.[getDpIdByCode(lightCode)] ?? false
  );

  const handleSave = async () => {
    const { hour, minute } = timeState;
    const time = `${String(hour).padStart(2, "0")}:${String(minute).padStart(
      2,
      "0"
    )}`;

    const dps = {
      [getDpIdByCode(switchCode)]: fanSwitch,
      [getDpIdByCode(lightCode)]: lightSwitch,
    };

    try {
      if (id) {
        await dispatch(
          updateTiming({
            id,
            time,
            loops: loops.join(""),
            aliasName: remark,
            dps: JSON.stringify(dps),
            actions: JSON.stringify({
              time,
              dps,
            }),
          })
        ).unwrap();
      } else {
        await dispatch(
          addTiming({
            time,
            loops: loops.join(""),
            aliasName: remark,
            dps: JSON.stringify(dps),
            actions: JSON.stringify({
              time,
              dps,
            }),
          })
        ).unwrap();
      }

      ty.showToast({
        title: Strings.getLang(id ? "dsc_edit_success" : "dsc_create_success"),
        icon: "success",
      });

      onClose();
    } catch (err) {
      ty.showToast({
        title: err?.message ?? Strings.getLang("dsc_error"),
        icon: "fail",
      });
    }
  };

  const handleTimeChange = (newTime) => {
    setTimeState(newTime);
    ty.vibrateShort({ type: "light" });
  };

  const handleFilterChange = (newLoops: string[]) => {
    setLoops(newLoops);
  };

  return (
    <PageContainer
      show={visible}
      customStyle="backgroundColor: transparent"
      position="bottom"
      overlayStyle="background: rgba(0, 0, 0, 0.1);"
      onLeave={onClose}
      onClickOverlay={onClose}
    >
      <View className={styles.container}>
        <View className={styles.header}>
          <TouchableOpacity className={styles.headerBtnText} onClick={onClose}>
            {Strings.getLang("dsc_cancel")}
          </TouchableOpacity>
          <Text className={styles.title}>
            {Strings.getLang(id ? "dsc_edit_timing" : "dsc_add_timing")}
          </Text>
          <TouchableOpacity
            className={clsx(styles.headerBtnText, "active")}
            onClick={handleSave}
          >
            {Strings.getLang("dsc_save")}
          </TouchableOpacity>
        </View>
        <View className={styles.content}>
          <TimePicker
            columnWrapClassName={styles.pickerColumn}
            indicatorStyle={{ height: "60px", lineHeight: "60px" }}
            wrapStyle={{
              width: "400rpx",
              height: "480rpx",
              marginBottom: "64rpx",
            }}
            is24Hour={false}
            value={timeState}
            fontSize="52rpx"
            fontWeight="600"
            unitAlign={language.includes("zh") ? "left" : "right"}
            onChange={handleTimeChange}
            amText={Strings.getLang("dsc_am")}
            pmText={Strings.getLang("dsc_pm")}
          />
          <WeekSelector
            value={loops}
            texts={WEEKS.map((item) =>
              Strings.getLang(`dsc_week_full_${item}`)
            )}
            onChange={handleFilterChange}
          />
          <View className={styles.featureRow}>
            <Text className={styles.featureText}>
              {Strings.getLang("dsc_remark")}
            </Text>
            <TouchableOpacity
              className={styles.featureBtn}
              onClick={() => setDialogVisible(true)}
            >
              <Text className={styles.remark}>{remark}</Text>
              <Text className="iconfontpanel icon-panel-angleRight" />
            </TouchableOpacity>
          </View>
          {checkDpExist(switchCode) && (
            <View className={styles.featureRow}>
              <Text className={styles.featureText}>
                {Strings.getDpLang(switchCode)}
              </Text>
              <Switch
                color="#6395f6"
                checked={fanSwitch}
                onChange={() => {
                  setFanSwitch(!fanSwitch);
                  ty.vibrateShort({ type: "light" });
                }}
              />
            </View>
          )}
          {checkDpExist(lightCode) && (
            <View className={styles.featureRow}>
              <Text className={styles.featureText}>
                {Strings.getDpLang(lightCode)}
              </Text>
              <Switch
                color="#6395f6"
                checked={lightSwitch}
                onChange={() => {
                  setLightSwitch(!lightSwitch);
                  ty.vibrateShort({ type: "light" });
                }}
              />
            </View>
          )}
        </View>
      </View>
      <DialogInput
        defaultValue={remark}
        onChange={setRemark}
        visible={dialogVisible}
        onClose={() => setDialogVisible(false)}
      />
    </PageContainer>
  );
};

export default TimingAdd;

When you upload the multilingual file, the multilingual settings of the page are included in the file. The field information is stored in the directory /src/i18n. In the i18n directory, it is recommended to configure at least two languages, Chinese and English.

For a general multilingual field, you can use:

import Strings from "@/i18n";

const text = Strings.getLang("dsc_cancel");

You can use the following code snippet if there is a DP state with multiple enumeration values, and each enumeration state corresponds to different languages:

import Strings from "@/i18n";

//dpCode is the identifier of a DP, and dpValue is the value of a DP
const text = Strings.getDpLang(dpCode, dpValue);