前提条件

构建内容

在 Codelab 中,您可以利用面板小程序开发构建出一个支持 1-5 路标准能力的照明光源品类面板,并结合智能设备模型及功能页等能力,快速实现以下功能:

学习内容

所需条件

详见 面板小程序 > 搭建环境

产品名称:照明光源一至五路灯

产品介绍

根据支持的白光、彩光 DP 不同,照明中灯分为一路至五路。

其含义为:

需求原型

功能汇总

当前照明光源模板必须的功能点见下表,其中调光涉及的功能 bright_valuecolour_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 类型功能点,建议查看具体协议描述,以便更好地进行开发。

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

注册登录 涂鸦开发者平台,并在平台创建产品:

  1. 单击页面左侧 产品 > 产品开发,在 产品开发 页面单击 创建产品
  2. 标准类目 下选择 照明,产品品类选择 光源,然后选择智能化方式和产品方案,并完善产品信息。
  3. 单击 创建产品 按钮,完成产品创建。
  4. 产品创建完成后,进入到 添加标准功能 页面,保持默认选择的标准功能即可,在后续实际开发过程中,可以根据实际需求自定义调整功能点。

🎉 在这一步,成功完成创建了一个照明光源产品。

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

面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。

详细操作步骤可以参考 面板小程序 > 创建面板小程序

IDE 基于模板创建项目

照明光源模板的仓库地址,请参考 仓库地址

  1. 打开 IDE,单击 新建,输入项目名称,选择要关联的面板小程序和调试产品,然后单击 下一步
  2. 选择模版 页面,选择 通用照明光源模板,单击 创建 ,IDE 会自动安装依赖并构建项目。
  3. 首次进入项目开发后,单击账户图标,出现二维码后,使用 智能生活 App 扫描二维码授权登录,用于授权 IDE 访问及控制智能设备。
  4. 如果当前授权 App 的家庭列表中不存在和当前关联产品匹配的设备,则需要手动添加一个虚拟设备,用于开发环境进行调试。
  5. 添加完虚拟设备后,出现如下图所示的信息,即说明项目创建成功。

工程目录

完成以上步骤后,一个面板小程序的开发模板初始化完成。在开始实现调光功能之前,首先介绍一下工程目录。

.
├── 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          # 智能设备类型定义文件

需求实现

生成产品定义文件(Schema)

参考代码见:src/devices/schema.ts

生成 SDM schema 至项目中,可以查看 src/devices/schema.ts,生成后使用配套的 usePropsuseActionsuseStructuredPropsuseStructuredActions 等 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

  1. 添加开关按钮组件,使用 useProps 获取设备当前 switch_led 开关的状态并展示不同的组件。
  2. 单击开关按钮组件时,使用 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.tsxsrc/components/Dimmer/Colour/index.tsx

  1. 分别添加彩光、饱和度、亮度滑动条组件,使用 useStructuredProps 获取设备当前 colour_data 数据里包含的 Hue、Saturation 和 Value。
  2. 滑动滑条时,使用 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);
    },
  });
};