在 Codelab 中,您可以利用面板小程序开发构建出一个支持 1-5 路标准能力的照明光源品类面板,并结合智能设备模型及功能页等能力,快速实现以下功能:
详见 面板小程序 > 搭建环境。
产品名称:照明光源一至五路灯
根据支持的白光、彩光 DP 不同,照明中灯分为一路至五路。
其含义为:
colour_data
、白光亮度 bright_value
、白光色温 temp_value
。switch_led
开关状态。colour_data
、bright_value
、temp_value
DP 时,表现为五路产品,即显示彩光及白光亮度和色温。colour_data
、bright_value
DP 时,表现为四路产品,即显示彩光及白光亮度。colour_data
DP 时,表现为三路产品,即只显示彩光。bright_value
、temp_value
DP 时,表现为二路产品,即只显示白光亮度及色温。bright_value
DP 时,表现为一路产品,只显示白光亮度。control_data
功能点存在,则通过 control_data
调节 DP 下发数据,该 DP 只下发不上报,需要通过节流控制每一条下发间隔 300ms,松手时下发 colour_data
彩光 DP。colour_data
DP 去更新 Index 值。control_data
调节 DP 下发数据。该 DP 只下发不上报,松手时色温下发 temp_value
,亮度下发 bright_value
DP。 bright_value
和 temp_value
DP 去更新 Index 值。countdown
DP,设备会在指定时间内自动开启或关闭。 music_data
DP,让用户感受音乐的魅力。 当前照明光源模板必须的功能点见下表,其中调光涉及的功能 bright_value
和 colour_data
必须存在任意一个,其他功能点可根据产品需求进行配置。
switch_led
work_mode
bright_value
colour_data
DP ID | 功能点名称 | 标识符 | 数据传输类型 | 数据类型 | 功能点属性 |
20 | 开关 | switch_led | 可下发可上报(rw) | bool | - |
21 | 模式 | work_mode | 可下发可上报(rw) | enum | 枚举值: white,colour,scene,music |
22 | 白光亮度 | bright_value | 可下发可上报(rw) | value | 数值范围: 10-1000,间距: 1,倍数: 0,单位: |
23 | 冷暖值 | temp_value | 可下发可上报(rw) | value | 数值范围: 0-1000,间距: 1,倍数: 0,单位: |
24 | 彩光 | colour_data | 可下发可上报(rw) | string | - |
25 | 场景 | scene_data | 可下发可上报(rw) | string | - |
26 | 倒计时 | countdown | 可下发可上报(rw) | value | 数值范围: 0-86400,间距: 1,倍数: 0,单位: s |
27 | 音乐律动 | music_data | 只下发(wr) | string | - |
28 | 实时调节 | control_data | 只下发(wr) | string | - |
30 | 生物节律 | rhythm_mode | 可下发可上报(rw) | raw | - |
33 | 断电记忆 | power_memory | 可下发可上报(rw) | raw | - |
34 | 停电勿扰 | do_not_disturb | 可下发可上报(rw) | bool | - |
35 | 开关渐变 | switch_gradient | 可下发可上报(rw) | raw | - |
对于部分 raw
类型功能点,建议查看具体协议描述,以便更好地进行开发。
首先需要创建一个产品,定义产品有哪些功能点,然后再在面板中一一实现这些功能点。
注册登录 涂鸦开发者平台,并在平台创建产品:
🎉 在这一步,成功完成创建了一个照明光源产品。
面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。
详细操作步骤可以参考 面板小程序 > 创建面板小程序。
照明光源模板的仓库地址,请参考 仓库地址。
完成以上步骤后,一个面板小程序的开发模板初始化完成。在开始实现调光功能之前,首先介绍一下工程目录。
.
├── public # 静态资源目录
│ └── images
├── ray.config.ts # Ray 配置文件
├── src
│ ├── api # 定义一些通用的 API
│ ├── components # 定义一些通用的组件
│ ├── config # 定义一些通用的配置
│ ├── constant # 定义一些通用的常量
│ ├── devices # 定义当前设备模型,需结合 SDM 使用
│ ├── hooks # 定义通用的一些 Hooks
│ ├── i18n # 定义当前项目用到的多语言
│ ├── pages # 定义当前项目的页面组件
│ ├── redux # 状态管理 Redux 目录
│ ├── utils # 定义一些通用的工具方法
│ ├── app.config.ts # 自动生成的文件配置,请勿修改
│ ├── app.less # 小程序级别的 Less 配置
│ ├── app.tsx # 根组件
│ ├── composeLayout.tsx # 处理通用业务逻辑的组件
│ ├── global.config.ts # 小程序全局配置文件
│ ├── routes.config.ts # 小程序路由配置文件
│ └── variables.less # 小程序 Less 变量
├── typings #业务类型定义目录
│ ├── lamp.d.ts # 照明业务相关通用类型定义文件
│ ├── sdm.d.ts # 智能设备类型定义文件
参考代码见:src/devices/schema.ts
生成 SDM schema
至项目中,可以查看 src/devices/schema.ts
,生成后使用配套的 useProps
、useActions
、useStructuredProps
、useStructuredActions
等 Hooks 可以自动基于产品定义文件生成的 Schema 进行类型推断。
export const defaultSchema = [
{
attr: 641,
canTrigger: true,
code: "switch_led",
defaultRecommend: true,
editPermission: false,
executable: true,
extContent: "",
iconname: "icon-dp_power",
id: 20,
mode: "rw",
name: "开关",
property: {
type: "bool",
},
type: "obj",
},
{
attr: 640,
canTrigger: true,
code: "work_mode",
defaultRecommend: true,
editPermission: false,
executable: true,
extContent: "",
iconname: "icon-dp_mode",
id: 21,
mode: "rw",
name: "模式",
property: {
range: ["white", "colour", "scene", "music"],
type: "enum",
},
type: "obj",
},
// ...
] as const; // 注意此处要加上断言,以便获得更好的 TS 类型提示;
参考代码见:src/components/ControlBar/index.tsx
useProps
获取设备当前 switch_led
开关的状态并展示不同的组件。useActions
下发设备当前 switch_led
开关状态。示例:
import React from "react";
import { View } from "@ray-js/ray";
import { useProps, useActions } from "@ray-js/panel-sdk";
export default function () {
// 获取当前设备的开关状态
const power = useProps((props) => props.switch_led);
const actions = useActions();
const handleTogglePower = React.useCallback(() => {
actions.switch_led.toggle({ throttle: 300 });
}, []);
return (
<View style={{ flex: 1 }}>
<View>switch_led: {power}</View>
<View onClick={handleTogglePower}>点击切换开关状态</View>
</View>
);
}
参考代码见:src/pages/Home/index.tsx、src/components/Dimmer/Colour/index.tsx
useStructuredProps
获取设备当前 colour_data
数据里包含的 Hue、Saturation 和 Value。useStructuredActions
下发设备当前 colour_data
数据中的特定数据。详细的接入指南可参考:智能设备模型 > 复杂 DP 协议数据获取及下发
示例:
import React from "react";
import { View } from "@ray-js/ray";
import { useProps, useActions } from "@ray-js/panel-sdk";
import {
LampColorSlider,
LampSaturationSlider,
LampBrightSlider,
} from "@ray-js/components-ty-lamp";
export default function () {
const colour = useStructuredProps((props) => props.colour_data);
const dpStructuredActions = useStructuredActions();
const handleTouchEnd = React.useCallback(
(type: "hue" | "saturation" | "value") => {
return (v: number) => {
dpStructuredActions[code].set(value, {
throttle: 300,
immediate: true,
});
};
},
[colour]
);
return (
<View style={{ flex: 1 }}>
<LampColorSlider
value={colour?.hue ?? 1}
onTouchEnd={handleTouchEnd("hue")}
/>
<LampSaturationSlider
hue={colour?.hue ?? 1}
value={colour?.saturation ?? 1}
onTouchEnd={handleTouchEnd("saturation")}
/>
<LampBrightSlider
value={colour?.value ?? 1}
onTouchEnd={handleTouchEnd("value")}
/>
</View>
);
}
参考代码见:src/composeLayout.tsx
进入面板后,在根组件根据自定义 Key 查询当前设备收藏颜色数据,并将获取到的数据通过 设备存储能力 缓存到云端和 App 本地,最后借助 Redux
将收藏颜色数据维护本地的全局状态里,方便后续页面或组件复用。
import React, { Component } from "react";
import { Provider } from "react-redux";
import { showLoading, hideLoading } from "@ray-js/ray";
import { utils } from "@ray-js/panel-sdk";
import store from "@/redux";
import { devices, dpKit } from "@/devices";
import defaultConfig from "@/config/default";
import { initCloud } from "./redux/modules/cloudStateSlice";
import "./styles/index.less";
import { CLOUD_DATA_KEYS_MAP } from "./constant";
const { defaultColors, defaultWhite } = defaultConfig;
interface Props {
devInfo: DevInfo;
extraInfo?: Record<string, any>;
preload?: boolean;
}
interface State {
devInfo: DevInfo;
}
const composeLayout = (Comp: React.ComponentType<any>) => {
return class PanelComponent extends Component<Props, State> {
async onLaunch() {
devices.lamp.init();
devices.lamp.onInitialized((device) => {
dpKit.init(device);
this.initCloudData();
});
}
/**
* 初始化设备维度缓存的云端数据,并同步到 Redux
*/
async initCloudData() {
showLoading({ title: "" });
const storageKeys = [
CLOUD_DATA_KEYS_MAP.collectColors,
CLOUD_DATA_KEYS_MAP.collectWhites,
];
return Promise.all(
storageKeys.map((k) => devices.lamp.model.abilities.storage.get(k))
)
.then((data) => {
// 在云端没有数据的情况下,使用默认值
const cloudData = {
[CLOUD_DATA_KEYS_MAP.collectColors]: [...defaultColors],
[CLOUD_DATA_KEYS_MAP.collectWhites]: [...defaultWhite],
} as Parameters<typeof initCloud>["0"];
data.forEach((v, i) => {
const storageKey = storageKeys[i];
if (v) {
const value = utils.parseJSON(v);
cloudData[storageKey] = value?.data?.value;
}
});
store.dispatch(initCloud(cloudData));
hideLoading();
})
.catch((err) => {
console.log("storage.get failed", err);
hideLoading();
});
}
render() {
const { extraInfo } = this.props;
return (
<Provider store={store}>
<Comp extraInfo={extraInfo} {...this.props} />
</Provider>
);
}
};
};
export default composeLayout;
参考代码见:src/components/CollectColors/index.tsx
在后续添加或删除颜色时,将数据同步到云端,如下述代码所示,通过 Redux
配合 StorageAbility
实现了收藏颜色的本地全局状态和云端数据状态维护。
注意:设备缓存的数据存在上限,建议不要超过 256 个字符,最大不允许超过 1024 个字符,否则会出现存储失败。
import React, { useState, useEffect } from 'react';
import _ from 'lodash-es';
import { View, showModal, showToast } from '@ray-js/ray';
import { utils, useSupport } from '@ray-js/panel-sdk';
import { useUnmount } from 'ahooks';
import clsx from 'clsx';
import { useSelector } from 'react-redux';
import Strings from '@/i18n';
import { Button } from '@/components';
import { devices } from '@/devices';
import { CLOUD_DATA_KEYS_MAP } from '@/constant';
import { ReduxState, useAppDispatch } from '@/redux';
import {
selectActiveIndex,
updateColorIndex,
updateWhiteIndex,
} from '@/redux/modules/uiStateSlice';
import {
selectCollectColors,
updateCollectColors,
updateCollectWhites,
} from '@/redux/modules/cloudStateSlice';
import styles from './index.module.less';
const { hsv2rgbString, brightKelvin2rgb } = utils;
const MAX_LENGTH = 8;
const MIN_LENGTH = 3;
interface IProps {
showAdd?: boolean;
style?: React.CSSProperties;
addStyle?: React.CSSProperties;
disable?: boolean;
isColor: boolean;
colourData: COLOUR;
brightness: number;
temperature: number;
chooseColor: (v: COLOUR & WHITE) => void;
addColor?: () => void;
deleteColor?: (v: any) => void;
}
export const CollectColors = (props: IProps) => {
const {
isColor,
chooseColor,
addColor,
addStyle,
deleteColor,
showAdd = true,
disable = false,
colourData,
brightness,
temperature,
style,
} = props;
const [animate, setAnimate] = useState(false);
const support = useSupport();
const dispatch = useAppDispatch();
const activeIndex = useSelector((state: ReduxState) => selectActiveIndex(state, isColor));
const collectColors = useSelector((state: ReduxState) => selectCollectColors(state, isColor));
useEffect(() => {
if (isColor) {
const newColorIndex = collectColors?.findIndex(item => {
const { hue: h, saturation: s, value: v } = item;
const { hue, saturation, value } = colourData;
return h === hue && saturation === s && value === v;
});
newColorIndex !== activeIndex && dispatch(updateColorIndex(newColorIndex));
} else if (support.isSupportTemp()) {
const newWhiteIndex = collectColors?.findIndex(item => {
const { brightness: b, temperature: t } = item;
return b === brightness && t === temperature;
});
newWhiteIndex !== activeIndex && dispatch(updateWhiteIndex(newWhiteIndex));
} else {
const newWhiteIndex = collectColors?.findIndex(item => {
const { brightness: b } = item;
return b === brightness;
});
newWhiteIndex !== activeIndex && dispatch(updateWhiteIndex(newWhiteIndex));
}
}, [colourData, temperature, brightness, isColor, collectColors]);
useUnmount(() => {
setAnimate(false);
});
const handleAddColor = () => {
if (activeIndex > -1) {
showModal({
title: Strings.getLang('repeatColor'),
cancelText: Strings.getLang('cancel'),
confirmText: Strings.getLang('confirm'),
});
} else {
const newList = [...collectColors, isColor ? { ...colourData } : { brightness, temperature }];
const storageKey = isColor
? CLOUD_DATA_KEYS_MAP.collectColors
: CLOUD_DATA_KEYS_MAP.collectWhites;
devices.lamp.model.abilities.storage
.set(storageKey, newList)
.then(() => {
if (isColor) {
dispatch(updateColorIndex(newList.length - 1));
dispatch(updateCollectColors(newList as ReduxState['cloudState']['collectColors']));
} else {
dispatch(updateWhiteIndex(newList.length - 1));
dispatch(updateCollectWhites(newList as ReduxState['cloudState']['collectWhites']));
}
})
.catch(err => {
showToast({ title: Strings.getLang('addColorFailed') });
console.log('storage.save addColor failed', err);
});
}
addColor?.();
};
const handleDeleteColor = item => {
const newList = _.cloneDeep(collectColors);
if (activeIndex > -1) {
newList.splice(activeIndex, 1);
const storageKey = isColor
? CLOUD_DATA_KEYS_MAP.collectColors
: CLOUD_DATA_KEYS_MAP.collectWhites;
devices.lamp.model.abilities.storage
.set(storageKey, newList)
.then(() => {
if (isColor) {
dispatch(updateColorIndex(-1));
dispatch(updateCollectColors(newList as ReduxState['cloudState']['collectColors']));
} else {
dispatch(updateWhiteIndex(-1));
dispatch(updateCollectWhites(newList as ReduxState['cloudState']['collectWhites']));
}
})
.catch(err => {
console.log('storage.save deleteColor failed', err);
});
}
deleteColor?.(item);
};
const handleChoose = (item, index) => {
if (!disable) {
chooseColor(item);
if (isColor) dispatch(updateColorIndex(index));
else dispatch(updateWhiteIndex(index));
setAnimate(true);
}
};
let isAddEnabled = collectColors.length < MAX_LENGTH && showAdd;
if (!isColor && !support.isSupportTemp()) isAddEnabled = false; // 白光仅支持亮度的情况下不支持添加
return (
<View className={styles.row} style={style}>
{isAddEnabled && (
<Button
img="/images/add_icon.png"
imgClassName={styles.icon}
className={clsx(styles.button, styles.add)}
style={addStyle}
onClick={handleAddColor}
/>
)}
<View
className={styles.colorRow}
style={{
marginLeft: isAddEnabled ? 108 : 0,
}}
>
<View style={{ display: 'flex' }}>
{collectColors?.map((item, index) => {
const isActive = index === activeIndex;
const bg = isColor
? hsv2rgbString(
item.hue,
item.saturation / 10,
(200 + 800 * (item.value / 1000)) / 10
)
: brightKelvin2rgb(
200 + 800 * (item.brightness / 1000),
support.isSupportTemp() ? item.temperature : 1000,
{ kelvinMin: 4000, kelvinMax: 8000 }
);
return (
<View
key={index}
className={`${styles.circleBox} ${styles.button}`}
style={{
marginRight: index === collectColors.length - 1 ? 0 : '24rpx',
border: isActive ? `4rpx solid ${bg}` : 'none',
background: 'transparent',
opacity: disable ? 0.4 : 1,
}}
onClick={() => handleChoose(item, index)}
>
<View
className={styles.circle}
style={{
backgroundColor: bg,
transform: `scale(${isActive ? 0.7 : 1})`,
transition: animate ? 'all .5s' : 'none',
}}
/>
{index + 1 > MIN_LENGTH && isActive && (
<Button
img="/images/delete_icon.png"
imgClassName={styles.deleteIcon}
className={clsx(styles.button, styles.noBg)}
onClick={() => handleDeleteColor(item)}
/>
)}
</View>
);
})}
</View>
</View>
</View>
);
};
参考代码见:src/components/Dimmer/White/index.tsx
通过 useSupport
Hooks,可以快速判断当前设备是否支持某些功能,比如是否支持色温调节、是否支持亮度调节等,从而在 UI 层面进行功能自适配,如下述代码所示,通过 isSupportTemp
方法动态控制了色温滑块的渲染条件。因此,如果在运行的设备上不存在色温 DP 时,该色温滑块将会隐藏。
注意:在使用 useSupport
之前,需要在设备定义目录中,置入 SmartSupportAbility
,参考 src/devices/index.ts。
import React from "react";
import { View } from "@ray-js/ray";
import { useProps, useActions, useSupport } from "@ray-js/panel-sdk";
import {
LampColorSlider,
LampSaturationSlider,
LampBrightSlider,
} from "@ray-js/components-ty-lamp";
export default function () {
const support = useSupport();
const temperature = useProps((dpState) => dpState.temp_value);
const actions = useActions();
const handleTouchEnd = React.useCallback((temp) => {
actions.temp_value.set(temp);
}, []);
return (
<View style={{ flex: 1 }}>
{support.isSupportTemp() && (
<LampTempSlider value={temperature} onTouchEnd={handleTouchEnd} />
)}
</View>
);
}
所以,想使一款设备面板同时适配 1-5 路灯,可以根据 1-5 路灯的产品定义,通过 useSupport
来判断当前设备支持的功能,然后根据支持的功能来动态渲染 UI。
参考代码见:src/global.config.ts
通过在 global.config.ts
全局配置注入的 functionalPages
配置项,可以定时倒计时功能页的 appid
从而后续实现一行代码直接跳转到对应的功能页。
import { GlobalConfig } from '@ray-js/types';
export const tuya = {
window: {
backgroundColor: 'black',
navigationBarTitleText: '',
navigationBarBackgroundColor: 'white',
navigationBarTextStyle: 'black',
},
functionalPages: {
// 定时倒计时功能页
rayScheduleFunctional: {
appid: 'tyjks565yccrej3xvo',
},
},
};
const globalConfig: GlobalConfig = {
basename: '',
};
export default globalConfig;
参考代码见:
通过 navigateTo
方法,可以直接跳转到定时倒计时功能页,如下述代码所示,通过 openScheduleFunctional
方法实现了一键跳转到定时倒计时功能页。
import { navigateTo } from '@ray-js/ray';
import { devices } from '@/devices';
import { getCachedLaunchOptions } from '@/api/getCachedLaunchOptions';
import { lampSchemaMap } from '@/devices/schema';
const { deviceId, groupId } = getCachedLaunchOptions()?.query ?? {};
export const openScheduleFunctional = async () => {
const { support } = devices.lamp.model.abilities;
const supportCountdown = support.isSupportDp(lampSchemaMap.countdown.code);
const supportCloudTimer = support.isSupportCloudTimer();
const supportRctTimer = false;
const url = `functional://rayScheduleFunctional/home?deviceId=${deviceId ||
''}&groupId=${groupId ||
''}&cloudTimer=${supportCloudTimer}&rtcTimer=${supportRctTimer}&countdown=${supportCountdown}`;
navigateTo({
url,
success(e) {
console.log('navigateTo openScheduleFunctional success', e);
},
fail(e) {
console.log('navigateTo openScheduleFunctional fail', e);
},
});
};