homebridge-gshが、有料のサブスクになったので、matter.jsを使う

現在、「google nest mini」と、「ラトックシステムのRS-WFIREX4(スマートリモコン)」と「NanoPi NEO(Raspberry pi みたいなシングルボードPC)」を使って、スマートホーム化してます。

まぁ、スマートホーム化と言っても、電気のオンオフするのと「子供がいつウンチしたか等の記録」だけに使ってます。

「子供がいつウンチしたか等の記録」は、ライトで制御していて、明るさが、例えば10%だったらウンチ、11%だったらオシッコ等と、明るさの数値によって、データを記録しています。

homebridgeを入れて「homebridge-gsh」プラグインを使っていたのですが、サブスクになり遂に動かなくなりました…

仕方がないので、matter.jsを導入してみたのですが、これが、かなり苦戦!めっちゃ時間がかかりましたが、何とか動くようになったので、その記録です。

参考URL

Matter対応デバイスの自作に挑戦、Node.jsの公開モジュールとラズパイを使う」で、無料登録されて、記事を参考にされるのが一番わかりやすいです。

Raspberry PiをMatter対応デバイスにしてAmazon Alexaから操作する」では、CPUが512MBのNanoPiでも、インストールできるように書かれてて助かりました。けど、matter.js のバージョンアップにより、ソースコードがずいぶん変更されてまして、ソースコードは使えませんでした…

インストール

NanoPi(512MB/32bit)なので、まずは、スワップファイルを作成してから、インストールします。ちなみに、4GBや8GBも試してみたのですが、2GBより多い場合、動く時と動かない時があったので、「NODE_OPTIONS=”–max-old-space-size=2048″」が良いです。

NanoPi NEO は、Raspberry pi zeroと、Raspberry pi zero 2w の間に位置する感じだと思いますので、Raspberry pi 4BやRaspberry pi 5 じゃなければ、「NODE_OPTIONS=”–max-old-space-size=2048″」を付けておく必要がありそうです。

とりあえず、なんでも大体「NODE_OPTIONS=”–max-old-space-size=2048″」を付けておけば、動きました。

// インストールフォルダを作成
mkdir matter
cd matter

// インストール
NODE_OPTIONS="--max-old-space-size=2048" npm init @matter

// 作成
NODE_OPTIONS="--max-old-space-size=2048" npm run build

// 動かす
npm run app

使っているコード

使用している matter.js のバージョンは、v0.16.0 です。

homebridge-gsh で動かしていた pythonファイルを再利用して動かしています。

#!/usr/bin/env node

// matter-run packages/examples/src/device-bridge-onoff/BridgedDevicesNode.ts

import { Endpoint, Environment, ServerNode, StorageService, Time, VendorId } from "@matter/main";
import { BridgedDeviceBasicInformationServer } from "@matter/main/behaviors/bridged-device-basic-information";
import { LevelControlServer } from "@matter/main/behaviors/level-control";
import { LevelControlCluster } from "@matter/main/clusters/level-control";
import { ThermostatServer } from "@matter/main/behaviors/thermostat";
import { FanControlServer } from "@matter/main/behaviors/fan-control";
import { Thermostat } from '@matter/main/clusters/thermostat';
import { FanControl } from '@matter/main/clusters/fan-control';

import { DimmableLightDevice } from '@matter/main/devices/dimmable-light';
import { ThermostatDevice } from '@matter/main/devices/thermostat';
import { FanDevice } from '@matter/main/devices/fan';
import { OnOffPlugInUnitDevice } from "@matter/main/devices/on-off-plug-in-unit";
import { AggregatorEndpoint } from "@matter/main/endpoints/aggregator";
import { spawn, execSync } from "node:child_process";

enum DeviceType {
    Light,
    Aircon,
    Fan
}
const deviceMap = {
    light: {
        name: "ライト",
        type: DeviceType.Light,
        cmd4: "python3 /home/***/cmd4_light.py"
    },
    children: {
        name: "子供の記録",
        type: DeviceType.Light,
        cmd4: "python3 /home/***/cmd4_children.py"
    },
    aircon: {
        name: "エアコン",
        type: DeviceType.Aircon,
        cmd4: "python3 /home/***/cmd4_aircon.py"
    },
    fan: {
        name: "サーキュレーター",
        type: DeviceType.Fan,
        cmd4: "python3 /home/***/cmd4_fan.py"
    }
} as const;

/** Initialize configuration values (設定値を初期化する) */
const { deviceName, vendorName, passcode, discriminator, vendorId, productName, productId, port, uniqueId } =
    await getConfiguration();

/**
 * Create a Matter ServerNode, which contains the Root Endpoint and all relevant data and configuration
 * ルートエンドポイントと関連するすべてのデータと構成を含むMatter ServerNodeを作成します。
 */
const server = await ServerNode.create({
    // Required: Give the Node a unique ID which is used to store the state of this node
    // 必須: このノードの状態を保存するために使用する一意のIDをノードに付与します
    id: uniqueId,

    // Provide Network relevant configuration like the port
    // ポートなどのネットワーク関連の設定を指定します。
    // Optional when operating only one device on a host, Default port is 5540
    // ホスト上で1台のデバイスのみを操作する場合はオプションです。デフォルトのポートは5540です。
    network: {
        port,
    },

    // Provide Commissioning relevant settings
    // 試運転関連の設定を提供する
    // Optional for development/testing purposes
    // 開発/テスト目的の場合はオプション
    commissioning: {
        passcode,
        discriminator,
    },

    // Provide Node announcement settings
    // ノードアナウンス設定を指定します
    // Optional: If Ommitted some development defaults are used
    // オプション: 省略した場合は開発時のデフォルト設定が使用されます
    productDescription: {
        name: deviceName,
        deviceType: AggregatorEndpoint.deviceType,
    },

    // Provide defaults for the BasicInformation cluster on the Root endpoint
    // ルートエンドポイントの基本情報クラスターのデフォルトを指定します
    // Optional: If Omitted some development defaults are used
    // オプション: 省略した場合は、開発時のデフォルトが使用されます
    basicInformation: {
        vendorName,
        vendorId: VendorId(vendorId),
        nodeLabel: productName,
        productName,
        productLabel: productName,
        productId,
        serialNumber: `matterjs-${uniqueId}`,
        uniqueId,
    },
});

/**
 * Matter Nodes are a composition of endpoints. Create and add a single multiple endpoint to the node to make it a
 * composed device. This example uses the DimmableLightDevice or OnOffPlugInUnitDevice depending on the value of the type
 * parameter. It also assigns each Endpoint a unique ID to store the endpoint number for it in the storage to restore
 * the device on restart.
 * Matterノードはエンドポイントの集合体です。ノードに複数のエンドポイントを1つ作成して追加することで、複合デバイスになります。
 * この例では、typeパラメータの値に応じてDimmableLightDeviceまたはOnOffPlugInUnitDeviceを使用します。
 * また、各エンドポイントに一意のIDを割り当て、ストレージにエンドポイント番号を保存することで、再起動時にデバイスを復元できるようにします。
 *
 * In this case we directly use the default command implementation from matter.js. Check out the DeviceNodeFull example
 * to see how to customize the command handlers.
 * この場合、matter.js のデフォルトのコマンド実装を直接使用します。
 * コマンドハンドラーをカスタマイズする方法については、DeviceNodeFull の例をご覧ください。
 */

const aggregator = new Endpoint(AggregatorEndpoint, { id: "aggregator" });
await server.add(aggregator);

const thermostatServerEx = ThermostatServer.with("Heating", "Cooling" );
// Heating = "Heating", 暖房
// Cooling = "Cooling", 冷房
// Occupancy = "Occupancy", 在室検知(OCC)
// ScheduleConfiguration = "ScheduleConfiguration", スケジュール設定(SCH)
// Setback = "Setback", セットバック(またはスパン)の設定
// AutoMode = "AutoMode", 自動モード(AUTO)
// LocalTemperatureNotExposed = "LocalTemperatureNotExposed", 局所温度非公開(LTNE)
// MatterScheduleConfiguration = "MatterScheduleConfiguration", Matter拡張スケジュール設定(MSCH)
// Presets = "Presets" プリセット(PRES)


const entries = Object.entries(deviceMap);
for (let i = 0; i < entries.length; i++) {
    const [key, device] = entries[i];
    const name = device.name;
    const type = device.type;
    const [command, file] = device.cmd4.split(/ (.+)/);
    let shouldHandleEvent = true;

    const endpoint = new Endpoint(
        ( type === DeviceType.Aircon )
            ? ThermostatDevice.with(BridgedDeviceBasicInformationServer, thermostatServerEx)
            :
        ( type === DeviceType.Fan )
            ? FanDevice.with(BridgedDeviceBasicInformationServer, FanControlServer)
            : DimmableLightDevice.with(BridgedDeviceBasicInformationServer, LevelControlServer),
        {
            id: `onoff-${i+1}`,
            bridgedDeviceBasicInformation: {
                nodeLabel: name, // Main end user name for the device
                productName: name,
                productLabel: name,
                serialNumber: `node-matter-${uniqueId}-${i+1}`,
                reachable: true,
            },
            ...(
            ( type === DeviceType.Aircon )
                ? { thermostat: {
                      minSetpointDeadBand: 2.0,
                      controlSequenceOfOperation: Thermostat.ControlSequenceOfOperation.HeatingOnly, // ID 4
                      systemMode: Thermostat.SystemMode.Cool, // Thermostat.SystemMode.Auto
                  },
                  setpointRaiseLower: async ({ raise, amount }: { raise: boolean; amount: number }) => {
                      const delta = raise ? amount : -amount;
                      console.log(`${key} の setpointRaiseLower で ${delta} されました`);
                      return;
                  },
                }
                :
            ( type === DeviceType.Fan )
                ? { fanControl: {
                    //fanMode: FanControl.FanMode.On, // Allows also to set the deprecated value?
                    fanModeSequence: FanControl.FanModeSequence.OffLowMedHigh,
                    //percentSetting: 50,
                    percentCurrent: 50,
                }, }
                : { levelControl: {
                    maxLevel: 254,
                    currentLevel: 177,
                }, }
            )
        }
    );

    await aggregator.add(endpoint);

    /**
     * Register state change handlers and events of the endpoint for identify and onoff states to react to the commands.
     * 識別状態とオンオフ状態がコマンドに反応できるように、エンドポイントの状態変更ハンドラーとイベントを登録します。
     *
     * If the code in these change handlers fail then the change is also rolled back and not executed and an error is
     * reported back to the controller.
     * これらの変更ハンドラーのコードが失敗した場合、変更もロールバックされ、実行されず、コントローラーにエラーが報告されます。
     */
    endpoint.events.identify.startIdentifying.on(() => {
        console.log(`Run identify logic for ${name}, ideally blink a light every 0.5s ...`);
    });

    endpoint.events.identify.stopIdentifying.on(() => {
        console.log(`Stop identify logic for ${name} ...`);
    });

    (endpoint.events as any).onOff?.onOff$Changed?.on(async (value: boolean) => {
        executeCommand(value ? `on${i+1}` : `off${i+1}`);
        console.log(`${key} が ${value ? "ON" : "OFF"} されました`);
        runSetCommand({command, file, key, property: "On", value: value ? "1" : "0"});

        // 初期設定を変更(ライトの場合のみ)
        if (value && key.startsWith("light")) {
            console.log(`${key} のON 初期値の設定`);

            shouldHandleEvent = false;
            await endpoint.set({
                levelControl: {
                    currentLevel: 177,
                }
            });
        }

    });

    if ("thermostat" in endpoint.events) {
        //endpoint.announce();
        endpoint.events.thermostat?.systemMode$Changed?.on((mode: number) => {
            console.log(`thermostat モード変更 ${mode}`);
            runSetCommand({command, file, key, property: "TargetHeaterCoolerState", value: String(mode)});
        });

        endpoint.events.thermostat?.occupiedCoolingSetpoint$Changed?.on((v: number) => {
          console.log(`冷房 setpoint ${v}`);
        });

        endpoint.events.thermostat?.occupiedHeatingSetpoint$Changed?.on((v: number) => {
          console.log(`暖房 setpoint ${v}`);
        });
    }

    if ("levelControl" in endpoint.events) {
        endpoint.events.levelControl.currentLevel$Changed.on(async (value: number | null, _old: number | null, _ctx: any) => {
            if (value !== null) {
                const percent = Math.round((value / 255) * 100);
                //executeCommand(`light${i+1}_${percent}`);
                console.log(`${key} の明るさが変更されました:`, percent);

                if (shouldHandleEvent) {
                    runSetCommand({command, file, key, property: "Brightness", value: String(percent)});
                }

                // ライトではない場合、常に100%にする
                if (!key.startsWith("light") && value < 254) {
                    shouldHandleEvent = false;
                    await endpoint.set({
                        levelControl: {
                            currentLevel: 254,
                        }
                    });
                } else {
                    shouldHandleEvent = true;
                }
            }
        });
    }
};

/**
 * In order to start the node and announce it into the network we use the run method which resolves when the node goes
 * offline again because we do not need anything more here. See the Full example for other starting options.
 * ノードを起動し、ネットワークにアナウンスするために、run メソッドを使用します。
 * このメソッドは、ノードが再びオフラインになったときに解決されます。ここではそれ以上何もする必要がないためです。
 * その他の起動オプションについては、完全な例を参照してください。
 * The QR Code is printed automatically.
 * QR コードは自動的に印刷されます。
 */
await server.start();

/**
 * Log the endpoint structure for debugging reasons and to allow to verify anything is correct
 * デバッグのためにエンドポイント構造をログに記録し、正しいかどうかを確認できるようにします。
 */
//logEndpoint(EndpointServer.forEndpoint(server));

/*
   To remove a device during runtime you can do so by doing the following:
   実行中にデバイスを削除するには、次の手順を実行します。
        console.log("Removing Light 3 now!!");

        await endpoint.close();

   This will automatically remove the endpoint from the bridge.
   これにより、エンドポイントがブリッジから自動的に削除されます。
 */

/*********************************************************************************************************
 * Convenience Methods
 *********************************************************************************************************/

/** 
 * Defined a shell command from an environment variable and execute it and log the response.
 * 環境変数からシェル コマンドを定義し、それを実行して応答をログに記録します。
 */
function executeCommand(scriptParamName: string) {
    const script = Environment.default.vars.string(scriptParamName);
    if (script === undefined) return undefined;
    console.log(`${scriptParamName}: ${execSync(script).toString().slice(0, -1)}`);
}

async function getConfiguration() {
    /**
     * Collect all needed data
     * 必要なデータをすべて収集する
     *
     * This block collects all needed data from cli, environment or storage. Replace this with where ever your data come from.
     * このブロックは、CLI、環境、またはストレージから必要なすべてのデータを収集します。データの取得元に応じて置き換えてください。
     *
     * Note: This example uses the matter.js process storage system to store the device parameter data for convenience
     * and easy reuse. When you also do that be careful to not overlap with Matter-Server own storage contexts
     * 注: この例では、利便性と再利用性を高めるため、matter.js プロセスストレージシステムを使用してデバイスパラメータデータを保存しています。
     * このストレージシステムを使用する場合は、Matter-Server 独自のストレージコンテキストと重複しないように注意してください
     * (so maybe better not do it ;-)).
     * (重複しない方が良いかもしれません ;-))。
     */
    const environment = Environment.default;

    const storageService = environment.get(StorageService);
    console.log(`Storage location: ${storageService.location} (Directory)`);
    console.log(
        'Use the parameter "--storage-path=NAME-OR-PATH" to specify a different storage location in this directory, use --storage-clear to start with an empty storage.',
    );
    const deviceStorage = (await storageService.open("device")).createContext("data");

    const deviceName = "Matter test device";
    const vendorName = "matter-node.js";
    const passcode = environment.vars.number("passcode") ?? (await deviceStorage.get("passcode", 20202021));
    const discriminator = environment.vars.number("discriminator") ?? (await deviceStorage.get("discriminator", 3840));
    // product name / id and vendor id should match what is in the device certificate
    // 製品名/IDとベンダーIDはデバイス証明書に記載されているものと一致する必要があります
    const vendorId = environment.vars.number("vendorid") ?? (await deviceStorage.get("vendorid", 0xfff1));
    const productName = `node-matter Bridge`;
    const productId = environment.vars.number("productid") ?? (await deviceStorage.get("productid", 0x8000));

    const port = environment.vars.number("port") ?? 5540;

    const uniqueId =
        environment.vars.string("uniqueid") ?? (await deviceStorage.get("uniqueid", Time.nowMs.toString()));

    // Persist basic data to keep them also on restart
    // 基本データを保持して再起動後も維持する
    await deviceStorage.set({
        passcode,
        discriminator,
        vendorid: vendorId,
        productid: productId,
        uniqueid: uniqueId,
    });

    return {
        deviceName,
        vendorName,
        passcode,
        discriminator,
        vendorId,
        productName,
        productId,
        port,
        uniqueId,
    };
}

/*********************************************************************************************************
 * 追加分
 *********************************************************************************************************/

/**
 * 指定されたコマンドを子プロセスで実行し、標準出力・エラーを監視する
 * @param command 実行するコマンド
 * @param key キー名
 * @param value 真偽値(true → "1", false → "0")
 * @param file 任意のファイルパス(省略可能)
 */
type CommandOptions = {
  command: string;
  file?: string;
  key: string;
  property: string;
  value: string;
};

function runSetCommand({ command, file, key, property, value }: CommandOptions): void {
  const args: string[] = [];

  if (file) args.push(file);

  args.push("Set", key, property, value);

  const child = spawn(command, args, {
    stdio: ['pipe', 'pipe', 'pipe']
  });

  child.stdout.on('data', (data: Buffer) => {
    console.log(`child process 標準: ${data.toString()}`);
  });

  child.stderr.on('data', (data: Buffer) => {
    console.error(`child process エラー: ${data.toString()}`);
  });

  child.on('close', (code: number) => {
    console.log(`child process 終了コード: ${code}`);
  });
}

コメント

タイトルとURLをコピーしました