本文档面向已经了解 面板小程序开发
的开发者,你需要充分的了解什么是面板小程序
产品功能
若您对上述概念有所疑问,我们推荐你去看一些预备知识。如果您已了解,可跳过本文。
面板
是运行在 智能生活 App
、OEM App(涂鸦定制 App)
上的界面交互程序,用于控制 智能设备
的运行,展示 智能设备
实时状态。产品
将 面板
与 智能设备
联系起来,产品描述了其具备的功能、在 App 上面板显示的名称、智能设备拥有的功能点等。智能设备
是搭载了 涂鸦智能模组
的设备,通常在设备上都会贴有一张二维码,使用 智能生活 App
扫描二维码,即可在 App 中获取并安装该设备的控制 面板
。产品
、面板
、设备
之间的关系可参考下图。照明光源模板使用 SDM(Smart Device Model)
开发,关于 SDM
相关可以 查看 SDM 文档
产品名称:照明光源一至五路灯
根据支持的白光、彩光 dp 不同,照明中灯分为一路至五路。
其含义为:
1.五路
- 支持彩光 colour_data
、白光亮度 bright_value
、白光色温 temp_value
2.四路
- 支持彩光、白光亮度
3.三路
- 支持彩光
4.二路
- 支持白光亮度、白光色温
5.一路
- 支持白光亮度
开关
:点击中间按钮切换 switch_led
开关状态。首页
:页面分为调光、开关、更多功能(断电记忆、停电勿扰、开关渐变等)面板根据产品所配置的功能点进行动态适配展示:colour_data
、bright_value
、temp_value
dp 时,表现为五路产品,即显示彩光及白光亮度和色温。colour_data
、bright_value
dp 时,表现为四路产品,即显示彩光及白光亮度。colour_data
dp 时,表现为三路产品,即只显示彩光。bright_value
、temp_value
dp 时,表现为二路产品,即只显示白光亮度及色温。bright_value
功能点,表现为一路产品,只显示白光亮度。彩光调节
: 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 值。断电记忆
:用于在产品断电重启之后,是否存储记忆上回用户设置的颜色、亮度色温、场景模式等。设置断电记忆需要维持当前设置 5 秒方可生效,面板具有以下三个功能选项: 停电勿扰
:断电记忆功能用于设置电源通断电灯光开启的状态,支持对每次从电源开启的默认灯光状态进行设置。智能灯具在停电后再次来电时会自动亮灯,如果是在半夜来电,灯光亮起来时会惊扰到用户,影响睡眠,而停电勿扰功能可以解决这个问题,更多详情可参考 照明通用文档 - 停电勿扰功能说明开关渐变
:通过 App 控制灯的开关,目前可以通过 IoT 平台配置开关过程是否渐变,但是渐变时间是固定的,不可调整。短时间内瞬间亮灯或灭灯,会使用户感觉灯光比较刺眼,影响用户使用感受。比如,在酒店场景下,客人进客房开灯的时候,开灯瞬间灯立刻亮起,灯光会特别刺眼。云定时
: 云定时功能,包括设定开关时间及周循环,无需硬件嵌入式开发,设备端只需实现定时开关功能,云端实现定时开关时间的设置和周循环的设置,需注意云定时和生物节律功能互斥,即云定时和生物节律功能不能同时开启。倒计时
: 倒计时功能设定后会下发 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 类型功能点,建议查看具体协议描述,以便更好地进行开发。
首先需要创建一个照明光源产品,定义产品有哪些功能点,然后面板中再根据这些功能点一一实现。
进入IoT 平台,点击左侧产品菜单,产品开发,创建产品,选择 标准类目 -> 照明 -> 光源
:
选择功能点,这里我们只需要默认的标准功能即可,后续实际开发过程中可以根据实际需求自定义调整功能点。
🎉 在这一步,我们创建了一个照明光源产品。
这部分我们在 小程序开发者
平台上进行操作,注册登录 小程序开发者平台。
上面的步骤我们已经初始化好了一个 面板小程序
的开发模板,下面我们介绍下工程目录。
.
├── public
│ └── images
├── ray.config.ts
├── 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
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/components/Dimmer/White/index.tsx
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>
);
}
参考代码见 src/composeLayout.tsx、src/components/CollectColors/index.tsx
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;
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>
);
};