智能产品App开发|家电通用模板开发教程

1. 前置知识

本文档面向已经了解 面板小程序开发 的开发者,你需要充分的了解什么是面板小程序 产品功能 若您对上述概念有所疑问,我们推荐你去看一些预备知识。如果您已了解,可跳过本文。

理解关系

面板作为 IoT 智能设备在 App 终端上的产品形态,创建产品之前,首先来了解一下什么是面板,以及和产品、设备之间的关系。

  1. 面板 是运行在 智能生活 AppOEM App(涂鸦定制 App) 上的界面交互程序,用于控制 智能设备 的运行,展示 智能设备 实时状态。
  2. 产品 将 面板 与 智能设备 联系起来,产品描述了其具备的功能、在 App 上面板显示的名称、智能设备拥有的功能点等。
  3. 智能设备 是搭载了 涂鸦智能模组 的设备,通常在设备上都会贴有一张二维码,使用 智能生活 App 扫描二维码,即可在 App 中获取并安装该设备的控制 面板
  4. 产品面板设备 之间的关系可参考下图。

相关概念

 

2. 需求分析

产品名称:智能风扇

需求原型

  1. 支持实时风速调节功能(开关风速控制等)
  2. 支持灯光开关功能
  3. 支持定时功能

 

3. 创建产品

首先需要创建一个家电类产品,定义产品有哪些功能点,然后面板中再根据这些功能点一一实现。

进入IoT 平台,点击左侧产品菜单,产品开发,创建产品,选择标准类目 -> 小家电 -> 风扇:

选择功能点,这里根据自己需求选择即可,这些功能未选择不影响视频预览。

🎉 在这一步,我们创建了一个名为 Fan的智能风扇产品。

 

4. 创建项目

开发者平台创建面板小程序

这部分我们在 小程序开发者 平台上进行操作,注册登录 小程序开发者平台

拉取并运行模板项目

1.通过 IDE 拉取模板

IDE 点击新建 -> 关联面板小程序和产品 -> 选择模板

2.通过 github 拉取模板

面板模板仓库

拉取项目

git clone https://github.com/Tuya-Community/tuya-ray-demo.git

进入 家电 模板,直接通过 IDE 导入源码目录,并关联到创建的小程序。

cd ./examples/panel-fan

导入项目到 IDE,并关联到已经创建的面板小程序与产品。

 👉 立即免费领取开发资源,体验小程序开发调试。

5. 核心功能

控制开关及风速

控制设备的开关及风速,需要根据 DP 状态和 和 DP 的定义来获取及下发对应的 DP 值

如您需要将风扇打开,则按照如下操作,从 hooks 中取出对应开关的状态,并根据开关状态来下发您对应的数据

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();
	// 获取到当前的设备开关状态
	const dpSwitch = useSelector(selectDpStateByCode(switchCode));

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

	// 绑定按钮点击事件,并使用节流函数进行节流
	const handleSwitch = useThrottleFn(
		() => {
			setPanelVisible(false);
			// 更新DP状态
			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>
	);
};

 

定时

如果您需要在合适的时间,让您的设备固定去执行对应的逻辑,比如说开关,您则可以使用定时 API 进行。

通常来讲定时涉及到了获取定时列表,添加定时,修改定时 和删除定时 4 个主要功能

主要涉及到的接口有如下

// 获取定时列表
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);
	}
};

// 添加定时
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);
	}
};
// 更新定时
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);
	}
};

// 删除定时
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);
	}
};

👉 立即免费领取开发资源,体验小程序开发调试。 

通常来讲,我们会把定时 API 与 Redux 结合到一起,来进行对应的 API 数据的更新

具体可以参考 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 --- 删除
		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 --- 关闭  1 --- 开启
		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;

您只需要按照与 DP 类似的操作,从 redux 获取并更新 redux 及可操作与云端的接口交互

例如添加定时

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(), []);

	// 编辑时的初始值
	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;

👉 立即免费领取开发资源,体验小程序开发调试。 

工程目录

上面的步骤我们已经初始化好了一个面板小程序的开发模板,下面我们介绍下工程目录。

ray-panel
├─ README.md
├─ .editorconfig
├─ .eslintrc.js
├─ .gitignore
├─ .npmrc
├─ .prettierrc.js
├─ commitlint.config.js
├─ package.json
├─ project.tuya.json
├─ ray.config.ts
├─ src
│  ├─ api
│  │  └─ index.ts
│  ├─ app.config.ts
│  ├─ app.tsx                       // 项目入口文件
│  ├─ components                    // 组件目录
│  │  ├─ connect.tsx
│  │  └─ index.tsx
│  ├─ config                        // 配置文件,根据需求删除或保留
│  │  ├─ dpCodes.ts
│  │  ├─ index.ts
│  │  └─ theme.ts
│  ├─ constant                      // 常量定义
│  │  └─ index.ts
│  ├─ global.config.ts              // 项目全局配置项,参照 https://developer.tuya.com/cn/ray/guide/tutorial/global-config
│  ├─ i18n                          // 多语言本地配置
│  │  ├─ index.ts
│  │  └─ strings.ts
│  ├─ kits.deps.json                // 由 IDE 生成,配置 TTT 能力依赖
│  ├─ pages                         // 页面目录,根据情况添加或删除
│  ├─ redux                         // redux 逻辑, 根据情况添加或删除
│  │  ├─ actions
│  │  │  ├─ common.ts
│  │  │  └─ theme.ts
│  │  ├─ index.ts
│  │  ├─ reducers
│  │  │  ├─ common.ts
│  │  │  └─ theme.ts
│  │  └─ store.ts
│  ├─ res                           // 资源目录,根据需求添加或删除
│  │  ├─ index.ts
│  ├─ routes.config.ts              // 路由配置 参照https://developer.tuya.com/cn/ray/guide/tutorial/routes
│  ├─ utils                         // 工具方法存放目录
│  │  └─ index.ts
│  └─ variables.less
├─ tsconfig.json
└─ typings
   └─ index.d.ts

 

6. 国际化

上传多语言时需将相应的多语言一并上传,字段信息在/src/i18n目录下。 i18n 中, 我们建议至少配置两种类型的语言。 一种中文,一种英文。 建议所有的多语言字段按照 dsc开头,并在每个单词中间使用作为分隔。如果是 dp 多语言,则以 dp开头,并在每个单词中间使用作为分隔。

使用一般多语言, 您可以使用

import Strings from "@/i18n";

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

如果存在类似于一个 DP 状态,有多个枚举值,每个枚举的状态对应的多语言不同, 您可以如下使用

import Strings from "@/i18n";

//dpCode 为DP的标识符, dpValue 为DP的值
const text = Strings.getDpLang(dpCode, dpValue);

👉 立即免费领取开发资源,体验小程序开发调试。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

IoT砖家涂拉拉

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值