PJLinkのテスト用アプリを使ってNode-RED(Python)からコマンドを送信して操作してみた

はじめに

PJLinkとは、プロジェクターをネットワーク経由で制御するための共通プロトコルです。一般社団法人ビジュアルインダストリー推進協議会(JBMIA)によって策定され、メーカーごとに異なっていたプロジェクター制御の仕様を統一することを目的としています。

PJLinkに対応したプロジェクターであれば、LAN経由で電源のON/OFF、入力切替、ランプ使用時間の取得など、基本的な操作を行うことができます。これにより、異なるメーカーのプロジェクターを1つのシステムで一括管理できるようになり、会議室や教室、ホールなどのAVシステムにおいて効率的な運用が可能になります。

PJLinkは大きく分けてClass 1Class 2の2つの仕様があります。Class 1では基本的なステータス取得や制御が可能で、Class 2ではそれに加えてセキュリティ機能(パスワード認証やハッシュ化)や拡張コマンドが用意されています。

通信はTCPポート4352を使用し、制御コマンドはテキストベースでやり取りされます。たとえば、プロジェクターの電源状態を確認するには「POWR ?」というコマンドを送信し、「POWR=1」のような応答が返ってくる仕組みです。

近年では、PJLinkに対応した制御アプリケーションや、Node-REDやPythonを用いた自動化システムと組み合わせるケースも増えており、AV機器の統合管理の中核を担う技術として注目されています。

アプリをダウンロード

上記リンクから「1-2-2.PJLink 試験ソフト」の右にあるダウンロードボタンをクリックして、PJLink_TestApplication210.zip』をダウンロードして解凍します。

解凍すると上記のようなファイルが入っております。今回使用するのは、「PJLinkTEST4CNT.exe」です。

テストアプリの使い方

PJLinkTEST4CNT.exeを実行すると上記のようなアプリが立ち上がります。ネットワークのポートを使用した通信を開始するので、Windowsなどのポート使用許可のメッセージが表示されたら「許可」をクリックしてアプリの操作に戻ります。

まず、デフォルト設定だと通信に使用するポートが「10000」と入力されているので、こちらを「4352」に変更していきます。

メニューバーの「Set up -> Network…」と進みます。

PJLink Port Noを「4352」で設定して、Passwordは必要に応じて変更してください。デフォルトだと「JBMIAProjectorLink」となっています。今回はデフォルトパスワードのまま設定してみます。

ポート番号の変更とパスワードの設定が済んだらアプリ側の設定は完了です。

Node-RED側のフロー

Injectノードから特定の値を渡してPythonを実行させるNode-REDのフローです。

実行するパスを指定して、pyファイルのIPアドレスを自分のPCのIPアドレスを指定すれば、動作すると思います。

以下、Node-RED用のコピペ用フローです。

[{"id":"75ed9142a0853970","type":"inject","z":"ddb1f15ae83d8947","name":"%1POWR ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"?","payloadType":"str","x":150,"y":360,"wires":[["6c3930a654c8b842"]]},{"id":"2fa57a7fea9513e3","type":"exec","z":"ddb1f15ae83d8947","command":"python","addpay":"payload","append":"","useSpawn":"false","timer":"","winHide":false,"oldrc":false,"name":"python","x":870,"y":880,"wires":[["fd27905eed888bbb"],[],[]]},{"id":"794928abf19f6fc7","type":"debug","z":"ddb1f15ae83d8947","name":"受信内容","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","x":1240,"y":880,"wires":[]},{"id":"e2ea5bed95855890","type":"inject","z":"ddb1f15ae83d8947","name":"%1POWR 1","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"1","payloadType":"str","x":150,"y":400,"wires":[["6c3930a654c8b842"]]},{"id":"bca0061bdb11d920","type":"inject","z":"ddb1f15ae83d8947","name":"%1POWR 0","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0","payloadType":"str","x":150,"y":440,"wires":[["6c3930a654c8b842"]]},{"id":"6c3930a654c8b842","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let powr = msg.payload;  // ← \"?\" or \"0\" or \"1\"\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_power_query.py\" \"${powr}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":400,"wires":[["c11116ce1aa0e501"]]},{"id":"884a32c2834703da","type":"comment","z":"ddb1f15ae83d8947","name":"Python経由でパスワードとトークンを発行して実行","info":"","x":250,"y":320,"wires":[]},{"id":"c11116ce1aa0e501","type":"delay","z":"ddb1f15ae83d8947","name":"","pauseType":"rate","timeout":"1","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":730,"y":880,"wires":[["2fa57a7fea9513e3"]]},{"id":"fd27905eed888bbb","type":"function","z":"ddb1f15ae83d8947","name":"UTF-8 decode","func":"if (Buffer.isBuffer(msg.payload)) {\n    msg.payload = msg.payload.toString('utf8');\n} else if (Array.isArray(msg.payload)) {\n    msg.payload = Buffer.from(msg.payload).toString('utf8');\n}\n\n// 各行の頭にある [??M] のような不明文字列を除去または置き換え\nmsg.payload = msg.payload.split('\\n').map(line => {\n    return line.replace(/^\\[.*?\\]\\s*/, '');  // []内とその後の空白を削除\n}).join('\\n');\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1060,"y":880,"wires":[["794928abf19f6fc7"]]},{"id":"inject001","type":"inject","z":"ddb1f15ae83d8947","name":"%1INPT ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"?","payloadType":"str","x":140,"y":500,"wires":[["b71b68fb2090e3ef"]]},{"id":"inject002","type":"inject","z":"ddb1f15ae83d8947","name":"%1INPT 11","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"11","payloadType":"str","x":140,"y":540,"wires":[["b71b68fb2090e3ef"]]},{"id":"inject003","type":"inject","z":"ddb1f15ae83d8947","name":"%1INPT 21","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"21","payloadType":"str","x":150,"y":580,"wires":[["b71b68fb2090e3ef"]]},{"id":"inject004","type":"inject","z":"ddb1f15ae83d8947","name":"%1INPT 31","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"31","payloadType":"str","x":150,"y":620,"wires":[["b71b68fb2090e3ef"]]},{"id":"inject005","type":"inject","z":"ddb1f15ae83d8947","name":"%1INPT 41","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"41","payloadType":"str","x":150,"y":660,"wires":[["b71b68fb2090e3ef"]]},{"id":"inject006","type":"inject","z":"ddb1f15ae83d8947","name":"%1INPT 51","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"51","payloadType":"str","x":150,"y":700,"wires":[["b71b68fb2090e3ef"]]},{"id":"inject007","type":"inject","z":"ddb1f15ae83d8947","name":"%1AVMT ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":760,"wires":[["25817049fe19f64a"]]},{"id":"inject008","type":"inject","z":"ddb1f15ae83d8947","name":"%1AVMT 11","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"11","payloadType":"str","x":150,"y":800,"wires":[["25817049fe19f64a"]]},{"id":"inject009","type":"inject","z":"ddb1f15ae83d8947","name":"%1AVMT 10","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"10","payloadType":"str","x":150,"y":840,"wires":[["25817049fe19f64a"]]},{"id":"inject010","type":"inject","z":"ddb1f15ae83d8947","name":"%1AVMT 21","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"21","payloadType":"str","x":150,"y":880,"wires":[["25817049fe19f64a"]]},{"id":"inject011","type":"inject","z":"ddb1f15ae83d8947","name":"%1AVMT 20","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"20","payloadType":"str","x":150,"y":920,"wires":[["25817049fe19f64a"]]},{"id":"inject012","type":"inject","z":"ddb1f15ae83d8947","name":"%1AVMT 31","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"31","payloadType":"str","x":150,"y":960,"wires":[["25817049fe19f64a"]]},{"id":"inject013","type":"inject","z":"ddb1f15ae83d8947","name":"%1AVMT 30","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"30","payloadType":"str","x":150,"y":1000,"wires":[["25817049fe19f64a"]]},{"id":"inject014","type":"inject","z":"ddb1f15ae83d8947","name":"%1ERST ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":1060,"wires":[["1953ab98c3232771"]]},{"id":"inject015","type":"inject","z":"ddb1f15ae83d8947","name":"%1LAMP ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":1120,"wires":[["f0453b7b0df209ac"]]},{"id":"inject016","type":"inject","z":"ddb1f15ae83d8947","name":"%1INST ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":1180,"wires":[["70d17a38bc0337ad"]]},{"id":"inject017","type":"inject","z":"ddb1f15ae83d8947","name":"%1NAME ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":150,"y":1240,"wires":[["678a58d7938aeed5"]]},{"id":"inject018","type":"inject","z":"ddb1f15ae83d8947","name":"%1INF1 ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":1300,"wires":[["7cdb763e824cb828"]]},{"id":"inject019","type":"inject","z":"ddb1f15ae83d8947","name":"%1INF2 ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":1360,"wires":[["403593d26743b415"]]},{"id":"inject020","type":"inject","z":"ddb1f15ae83d8947","name":"%1INF3 ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":1420,"wires":[["96b1d78bd09d30ec"]]},{"id":"inject021","type":"inject","z":"ddb1f15ae83d8947","name":"%1INFO ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":1480,"wires":[["cff5c74d92897976"]]},{"id":"inject022","type":"inject","z":"ddb1f15ae83d8947","name":"%1CLSS ?","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","payload":"?","payloadType":"str","x":140,"y":1540,"wires":[["bed99ba199ab3869"]]},{"id":"b71b68fb2090e3ef","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let inpt = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_inpt_query.py\" \"${inpt}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":580,"wires":[["c11116ce1aa0e501"]]},{"id":"25817049fe19f64a","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let avmt = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_avmt_query.py\" \"${avmt}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":880,"wires":[["c11116ce1aa0e501"]]},{"id":"1953ab98c3232771","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let erst = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_erst_query.py\" \"${erst}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1060,"wires":[["c11116ce1aa0e501"]]},{"id":"f0453b7b0df209ac","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let lamp = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_lamp_query.py\" \"${lamp}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1120,"wires":[["c11116ce1aa0e501"]]},{"id":"70d17a38bc0337ad","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let inst = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_inst_query.py\" \"${inst}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1180,"wires":[["c11116ce1aa0e501"]]},{"id":"678a58d7938aeed5","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let name = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_name_query.py\" \"${name}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1240,"wires":[["c11116ce1aa0e501"]]},{"id":"7cdb763e824cb828","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let inf1 = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_inf1_query.py\" \"${inf1}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1300,"wires":[["c11116ce1aa0e501"]]},{"id":"403593d26743b415","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let inf2 = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_inf2_query.py\" \"${inf2}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1360,"wires":[["c11116ce1aa0e501"]]},{"id":"96b1d78bd09d30ec","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let inf3 = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_inf3_query.py\" \"${inf3}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1420,"wires":[["c11116ce1aa0e501"]]},{"id":"cff5c74d92897976","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let info = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_info_query.py\" \"${info}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1480,"wires":[["c11116ce1aa0e501"]]},{"id":"bed99ba199ab3869","type":"function","z":"ddb1f15ae83d8947","name":"値とファイル指定","func":"let clss = msg.payload;\nmsg.payload = `\"C:\\\\VisualStudioCode_ProjectFile\\\\PJLink\\\\pjlink_clss_query.py\" \"${clss}\"`;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1540,"wires":[["c11116ce1aa0e501"]]}]

必要に応じて各pyファイルを特定の場所に保存してください。

pjlink_power_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password, power_value):
    command = f"%1POWR {power_value}\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # 認証チャレンジの受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] not in ["?", "0", "1"]:
        print("使い方: いずれかの値を渡すと実行可能です [ ? | 0 | 1 ]")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD, sys.argv[1])

pjlink_inptquery.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password, inpt_value):
    # 入力コマンド生成(INPT ? または INPT XX)
    command = f"%1INPT {inpt_value}\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLINK認証チャレンジを受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] not in ["?", "11", "21", "31", "41", "51"]:
        print("使い方: python pjlink_inpt_query.py [ ? | 11 | 21 | 31 | 41 | 51 ]")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD, sys.argv[1])

pjlink_avmt_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password, avmt_value):
    # AVMT コマンド作成
    command = f"%1AVMT {avmt_value}\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLINKチャレンジ受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    valid_values = ["?", "11", "10", "21", "20", "31", "30"]
    if len(sys.argv) != 2 or sys.argv[1] not in valid_values:
        print(f"使い方: python pjlink_avmt_query.py [ {', '.join(valid_values)} ]")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD, sys.argv[1])

pjlink_erst_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # ERSTコマンドは状態取得のみ
    command = "%1ERST ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLINK認証チャレンジの受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_erst_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

pjlink_lamp_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # LAMP コマンド(使用時間取得)
    command = "%1LAMP ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLink 認証チャレンジ受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_lamp_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

pjlink_inst_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # INST コマンド(設置方向の取得)
    command = "%1INST ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLink認証チャレンジ受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_inst_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

pjlink_name_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # NAME コマンド(プロジェクター名の取得)
    command = "%1NAME ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLink認証チャレンジの受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_name_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

pjlink_inf1_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # INF1 コマンド(製品名の取得)
    command = "%1INF1 ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLink認証チャレンジの受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_inf1_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

pjlink_inf2_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # INF2 コマンド(メーカー名の取得)
    command = "%1INF2 ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLink認証チャレンジの受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_inf2_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

pjlink_inf3_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # INF3 コマンド(その他情報の取得)
    command = "%1INF3 ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLink認証チャレンジ受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_inf3_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

pjlink_info_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # INFO コマンド(入力信号の有無を取得)
    command = "%1INFO ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLink認証チャレンジの受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_info_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

pjlink_clss_query.py

import socket
import hashlib
import sys

# 設定
HOST = "192.168.1.15"
PORT = 4352
PASSWORD = "JBMIAProjectorLink"

def pjlink_authenticated_command(host, port, password):
    # CLSS コマンド(対応PJLinkクラスの取得)
    command = "%1CLSS ?\r"

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(5.0)
        s.connect((host, port))

        # PJLink認証チャレンジ受信
        challenge = s.recv(1024).decode("utf-8").strip()
        print(f"[受信] {challenge}")

        if not challenge.startswith("PJLINK 1"):
            print("認証不要または不正な応答")
            return

        seed = challenge.split(" ")[2]
        hash_str = hashlib.md5((seed + password).encode("utf-8")).hexdigest()

        full_command = hash_str + command
        print(f"[送信] {full_command.strip()}")

        # コマンド送信
        s.sendall(full_command.encode("utf-8"))

        # 応答受信
        response = s.recv(1024).decode("utf-8").strip()
        print(f"[応答] {response}")

if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] != "?":
        print("使い方: python pjlink_clss_query.py ?")
        sys.exit(1)

    pjlink_authenticated_command(HOST, PORT, PASSWORD)

実行結果

上記画像は「%1POWR 1」のInjectノードをクリックさせてプロジェクターの電源をONさせたときの内容です。GUIのアプリ側の特に操作をしていないのに、Node-REDからコマンドを実行することでPower Controlの項目が「Power ON」に切り替わりました。

Pythonを実行させてUTF-8でデコードを掛けるとデバッグノードに出力される受信内容が分かりやすくなります。同様に他のInjectノードを実行するとAPIに応じた内容でコマンドが実行されますので、色々試してみてください。

コマンドの内容について解説

PJLinkクラス1コマンド一覧(%1 接頭)

コマンド 説明 送信例 応答例
POWR 電源状態の取得・制御 %1POWR ? %1POWR=1(電源ON)
    %1POWR 0 %1POWR=OK(電源OFF)
    %1POWR 1 %1POWR=OK(電源ON)
INPT 入力切替・取得(11=RGB1など) %1INPT ? %1INPT=21
    %1INPT 11 %1INPT=OK
AVMT ミュート状態制御(映像/音声/両方) %1AVMT ? %1AVMT=11(映像ミュートON)
    %1AVMT 10 %1AVMT=OK
    %1AVMT 31 %1AVMT=OK
ERST エラー状態(4桁コード) %1ERST ? %1ERST=0000(正常)
LAMP ランプ使用時間と点灯状態 %1LAMP ? %1LAMP=1200 1(時間、点灯中)
INST 設置方向(1=前面、3=天吊など) %1INST ? %1INST=1
NAME プロジェクター名 %1NAME ? %1NAME=EPSON_PROJ
INF1 製品名(モデル) %1INF1 ? %1INF1=EB-L630SU
INF2 メーカー名 %1INF2 ? %1INF2=EPSON
INF3 その他情報(ファームVerなど) %1INF3 ? %1INF3=FW 1.23.5
INFO 入力信号の有無(最大5系統) %1INFO ? %1INFO=1 0 0 1 0
CLSS PJLinkのクラス(1または2) %1CLSS ? %1CLSS=1

💡 使い方補足

  • 送信する前に**認証(チャレンジ応答)**が必要です(PJLINK 1 xxxxxxxx に対してMD5)
  • %1 は**プロジェクター番号(1台目)**を表します。複数対応の場合は %2, %3… などに。
  • 応答は "= で結果が返る。操作系は =OK、状態系は =値

🔧 拡張ヒント

機能 使用コマンド
電源ON/OFFボタン POWR
入力選択UI INPT
ミュート切替 AVMT
エラー監視 ERST
ランプ交換管理 LAMP
ダッシュボード表示 INFO, NAME, INF1〜3

まとめ

実行コマンドに対してpyファイルが1対1の複数に分かれた内容となりましたが、とりあえずシンプルに実行できる設計にしてみました。もっと柔軟な作りにすれば、1つのpyファイルですべてのコマンドに対応できる内容に変更出来ると思います。

今回はテストアプリでの実行確認となりましたが、実運用させるための本番用のアプリは関係者の一部にのみ限定公開されている状態なので、一般向けではない内容となります。

事前にこのテストアプリからノウハウを学んでおけば、本番用のアプリケーションと連携させるとき、ネットワークトラブルなどに遭遇しても、事前確認ができていれば多少なりとも自信が付きそうな予感です。

まだプロジェクターが現場に届いていないけど、IPアドレスは決まっているので先に動作テストをしたいという際に役に立ちそうな気がします。