herdrでIMEと競合しないキー使えるようにPlugin書いた

先週末くらいから今流行りの AI Agentフレンドリーな

GitHub - ogulcancelik/herdr: agent multiplexer that lives in your terminal. · GitHub

つかってます。

で、ほぼほぼ文句ないのでtmuxから乗り換えようとしているんだけど、唯一の不満点は作業スペース一覧で、カーソル移動とかのキーが変更できないこと。

herdrは prefix + g で作業スペース一覧開いてそれで移動するのが気持ちいいんだよね。

例えば prefix+g でgoto パネル開いて b キーでblockedになっているもので絞ってエンター押すと入力待ちのAgentに直接飛べるのがめっちゃ気持ちいい。

めっちゃ気持ちいいんだけど、デフォのキー設定だと日本語入力のIMEがonになっているとbがそっちに取られちゃって動かない...毎回 IME off にしてからやればいいんだけど、僕はClaude codeと日本語で喋っているので毎回毎回リズムが崩れるんだよね。

他にもgotoパネルは jk とかのvi-likeなキーで移動できるんだけど、これもIMEに食われちゃう。emacs-likeな Ctrl+n とか Ctrl+p とか使えると実はIMEに吸われないので僕はtmuxもそのへん使って色々設定してた。

けど、色々調べて(最終的にはコード読んで)herdrはgotoパネルはキーコンフィグ出来ないと言うことが分かった。

せっかくいい感じなのにそこだけちょっと気になったんだけど、ソースコード読んだりしているときにherdrはプラグイン機能があることがわかったので、keyconfig出来るgotoパネル的なのを作ってみた。

herdr plugin install yoshiori/herdr-configurable-picker

で、インストールできます。呼び出しキーはherdr側のconfigに1エントリ足す方式です。READMEのサンプル通り prefix+Ctrl+g にしておけばIME気にせずに常に呼び出せます。

[[keys.command]]
key = "prefix+ctrl+g"
type = "plugin_action"
command = "yoshiori.herdr-configurable-picker.open"

あとは Ctrl+n とか Ctrl+p で上下に移動してエンターで移動できます。

まぁ、でもぶっちゃけて言うと一番気持ちいいのは複数エージェント走らせているときに、どっかが入力待ちになったら

prefix+Ctrl+g -> Ctrl+b -> enter

で即入力待ちのパネルに飛ぶやつ。自分で作ったこのプラグインのおかげで herdr 生活快適になってもう手放せなくなりました。 よかったら使ってみてね!

自転車用ガジェットの充電基地をLEGOとM5Stackで作った

自転車って充電するもの多すぎ問題

自転車乗ってる人なら分かると思うんですけど、周辺機器の充電が地味にめんどくさいんですよね。

昔は反射板で済んでたリアライトも、今は安全のために電気で光らせるのが普通。
原チャリとかと同じで、昼間でもフロントライトつけといた方がいい、みたいな流れもあって、前後ライト両方とも充電式。
で、そこにサイクルコンピューター(サイコン)がついて、空気入れも電動の手軽なやつがあるんで追加、ちょっと長距離のるなら心拍計的なの付けたくなるので( Apple Watch じゃ精度足りない)ちゃんと密着する心拍計も追加。

気づくと、自転車のためだけに充電しなきゃいけないものが5個くらい転がってるんですよ。なので、これらを全部まとめて置ける充電台を作ることにした。

LEGOで組んで、M5Stackを乗せた

充電のベースはLEGOで組んだ。3Dプリンタとか持っている人はそういうのでかっこよく作れるんだろうけど残念ながら持っていないのでね。
で、せっかく作るならってことで M5Stackを一緒に乗っけて、いろんな情報を表示させることにした。

表示してるのはこんな感じ。

  • 外の気温と風向き — 天気APIを叩いて取得
  • 今週の走行距離と累計距離 — Strava のAPIから持ってくる。サイコンが Strava に自動アップしてくれるので、勝手に最新になる
  • チェーンの注油タイミング — 走った距離から判定。しかも前回のライドが雨だったら油が落ちてるから、注油タイミングを早める
  • 空気入れてから何日経ったか
  • タイヤ交換してからの走行距離

下にリセットボタンつけてあって、空気入れた後とかチェーン注油した後にピッと押すと、カウントが0に戻る。
しばらく使ってみたけど、まぁまぁちゃんと機能してるし、何より見た目が「ちょっとした基地」っぽくて気に入ってる。

UIを全部AIに投げられる時代

こういうの作るとき、裏のロジック組むのは得意なんですよ。APIを叩いて、条件分岐させて、みたいなの。でも、ディスプレイに何をどうレイアウトして見せるか、みたいな UIのところが昔からめちゃくちゃ苦手で。

それが、今は完全にAIに任せられる。「こういう情報をこの画面サイズで見やすく並べたい」って投げると、ちゃんと作ってくれるので助かるなぁと。

github.com

Ruby で作る Coding Agent

Coding Agent が簡単に作れるよってよく聞くので、理解のために自分でも実装してみました。

なんかググってもNode.jsとかPythonとかGoとかで書かれているのばかりなのでせっかくだし自分が大好きなRubyで!

リポジトリはここ: https://github.com/yoshiori/r2d2/

ModelとかはとりあえずGeminiつかってみる感じで、各セクションのその時のコミット貼って置きます。

最初のコード

https://github.com/yoshiori/r2d2/commit/7c6145961bd6b71696c4630f55c86a6f25ec3556

プロンプトのテキストとかは長いので雑にコードを書くと最初はこれだけ。

  1. ユーザーの入力受け取る
  2. Gemini に問い合わせ
  3. レスポンスを表示

を loop で無限にループするだけです。これがベースになるコード。

  def self.start(_args)
    puts "R2d2 is starting..."
    loop do
      input = gets.chomp
      response = gemini.stream_generate_content({
        contents: { role: "user", parts: { text: input } }
      })
      response.each do |message|
        message["candidates"].each do |candidate|
          candidate.dig("content", "parts").each do |part|
            puts "R2d2: #{part["text"]}"
          end
        end
      end
    end
  end

履歴を持つようにする

https://github.com/yoshiori/r2d2/commit/fa2ef21a3b6b8464cdf18e75e9e193c5416693c3

さっきの実装だけでも最低限動くんだけど会話履歴持ってないので毎回ボケ老人と会話しているみたいになる。 なので会話履歴持つようにする。 会話履歴と言っても簡単で話した内容を配列で持っておいて送るだけ。なので @history っていうインスタンス変数保持しておいてそこに追加して今までのtextのかわりにそれを送るだけ。受け取ったらそれも追加しておく。

role は user と model 交互にあることが期待されているのでそこだけ注意

  def chat(text)
    messages = []
    @history << { role: "user", parts: { text: text } }
    response = gemini.generate_content({
        contents: @history,
        system_instruction: { parts: { text: PROMPT } }
    })
    p response
    response["candidates"].each do |candidate|
      candidate.dig("content", "parts").each do |part|
        messages << part["text"]
        @history << { role: "model", parts: { text: part["text"] } }
      end
    end
    messages
  end

という感じ。

ツールを作る

https://github.com/yoshiori/r2d2/commit/dd32bd7009a27afabb7468d64ced40cc912e0876

ここまでだとチャットを実現しただけなので、このへんからコーディングエージェントっぽいことをやっていく。と言ってもあとはエージェントにツールをどんどん追加するだけ。

例えばファイルを読むツールは下記のように定義する

    @tools = {
      function_declarations: [
        {
          name: "read_file",
          description: "Read the contents of a file at the given path. " \
               "Use this to understand code, gather context, or inspect files " \
               "before answering questions or making decisions.",
          parameters: {
            type: "object",
            properties: {
              path: {
                type: "string",
                description: "The relative file path from the current working directory"
              }
            },
            required: ["path"]
          }
        }
      ]
    }

そう、MCP の定義と一緒。まぁ、そりゃそうか

このツールをリクエストに含めるようにします。

    response = gemini.generate_content({
        contents: contents,
        contents: @history,
        tools: @tools,
        system_instruction: { parts: { text: PROMPT } }
    })

そうするとレスポンスにfunctionCallというのが含まれるようになります。そこにどのツール使いたくて引数は何なのかとかが入っています。 なので、それを使ってて実行するようにする。たとえばさっきの read_file だと読みたいファイルのパスが入ってくるのでその中身をまたリクエストとして投げてあげれば良い。 これらもさっきの @history に全部入れて投げる。

 @history << { role: "model", parts: parts }

  function_response = []
  parts.each do |part|
    if part["functionCall"]
      name = part["functionCall"]["name"]
      args = part["functionCall"]["args"]

      result = ReadFile.new.execute(args["path"])
      function_response << { functionResponse: { name: name, response: { result: result } } }
    else
      messages << part["text"]
    end
  end

  unless function_response.empty?
    @history << { role: "user", parts: function_response }
    messages.concat(generate)
  end

ちなみに ReadFile の初期実装はこんなに簡単

class ReadFile

  def execute(file_path)
    File.read(file_path)
  end
end

一旦リファクタリング

https://github.com/yoshiori/r2d2/commit/61ab9c853a8afd37eb69e3dc353c9618e8acd53c

これでhistoryとtoolsという最低限のやりたいことは揃ったのでリファクタリング。今回はこのへんは趣旨に反するので省略する。

ツールを充実させる

https://github.com/yoshiori/r2d2/commit/7402cb84ef0f3c5bb8dc1f609d757277e5ffd6dd

こうやって1個づつツールを作っていくのもいいんだけど、もっと雑にコマンド実行出来るようにしておけば柔軟性が高いのでそれを作る。例えばファイル一覧ほしいときとかもAIが勝手に ls 叩いてくれれば良いので。

require "open3"

class ExecCommand
  def self.name
    "exec_command"
  end
  def self.description
    "Execute a shell command and return its output. " \
    "Supports any UNIX command (ls, grep, find, cat, curl, etc.)."
  end
  def self.parameters
    {
      type: "object",
      properties: {
        command: {
          type: "string",
          description: "The command to execute"
        },
        args: {
          type: "array",
          description: "Array of arguments for the command",
          items: {
            type: "string"
          }
        }
      },
      required: ["command"]
    }
  end
  def self.definition
    { name: name, description: description, parameters: parameters }
  end
  def execute(command:, args: [])
    output, status = Open3.capture2e(command, *args)
    output
  end
end

こんな感じのクラス作って追加するだけ。簡単なファイル書き込みもコマンドでやってくれるので最低限のコーディングエージェントとしての動きは出来た。

ファイル書き込みTool追加

https://github.com/yoshiori/r2d2/commit/2b3c45588c6de726454a447e1fe20ab0517b1246

上記のコマンド実行作成したらほぼ動いたんだけどファイルに書き込むのだけはたまにリダイレクトでは失敗することが合ったのでファイルに書き込むtoolを追加する。

class WriteFile
  def self.name
    "write_file"
  end

  def self.description
    "Write content to a file. If the file already exists, it will be overwritten."
  end

  def self.parameters
    {
      type: "object",
      properties: {
        path: {
          type: "string",
          description: "The relative file path from the current working directory"
        },
        content: {
          type: "string",
          description: "The content to write to the file"
        }
      },
      required: %w[path content]
    }
  end

  def self.definition
    { name: name, description: description, parameters: parameters }
  end

  def execute(path:, content:)
    FileUtils.mkdir_p(File.dirname(path))
    File.write(path, content)
    "Successfully wrote to #{path}"
  end
end

これで終わり。

テストの実装くらいは出来る

これくらい実装してあればある程度使えます。 テストを書いていなかったのでテスト書かせましょう。

実際に自分でテスト書かせてるデモはこちら ↓


www.youtube.com

Historyの圧縮

https://github.com/yoshiori/r2d2/commit/5365c589b4e84e1d541b5e46d378f26bbcafdcd5

結構対話を繰り返していると token が足りなくなることがあります。適度なタイミングで圧縮するとよいのでそれを実装します。 Responceには送信した内容(contents + system_instruction)のtoken数、つまりプロンプトと今までの会話のtoken数が入っています。なのでそれをみて閾値を超えそうだったら圧縮しましょう。

prompt_tokens = response.dig("usageMetadata", "promptTokenCount") || 0
compress_history! if prompt_tokens > TOKEN_LIMIT

感覚的には古い会話は圧縮したいので、直近の会話はそのままとっておきます。とりあえず僕は10個くらいはとっておくようにしています。 ここで注意点なのですが、無差別に10件で切っちゃうとツールを使った履歴が途中で切れておかしなことになってしまいます。 なので functionCall/functionResponse ペアが壊れない位置で分割します。そして最後がmodelになるようにします。

 def find_safe_split_index
    from = @history.size - RECENT_KEEP_COUNT

    index = @history[0...from].rindex do |msg|
      msg[:role] == "model" && msg[:parts].none? { |p| p["functionCall"] }
    end

    index ? index + 1 : 0
  end

これで分割位置は決まったので、これより前を圧縮します。それようのプロンプト作って投げればいいだけです。

  def compress_history!
    split_at = find_safe_split_index
    return if split_at <= 0

    old_history = @history[0...split_at]
    recent_history = @history[split_at..]

    summary_response = gemini.generate_content({
        contents: old_history + [{ role: "user",parts: { text: SUMMARIZE_PROMPT } }]
    })

    summary_text = summary_response.dig("candidates", 0, "content", "parts", 0, "text")

    @history = [
      { role: "user", parts: { text: "Summary of the conversation so far:\n#{summary_text}" } },
      { role: "model", parts: [{ text: "Understood. Let's continue." }] }
    ] + recent_history

    puts Reinbow("History compressed: #{old_history.size} messages summarized").faint
  end

終わり

自分で実装してみると思いの外簡単に実装できましたね。僕自信は色々な発見があって面白かったです。 あとは汎用的なAgentにしてもいいし、なにかに特化したエージェントにしたりしても面白いですね。Web検索するtoolつくったりも残っているので引き続き盆栽的に楽しもうと思います。

買収されてからの近況と今の仕事面白よって話と採用ネジ込みたい話

こんにちはヨシオリです。 めちゃくちゃ久しぶりなんですが、最近の会社の状況がかなり面白くなってきているのと、エンジニアの採用枠がちょうど「1個」空いているので、それに応募してほしくて久しぶりに書いています。

最初に、今やってる仕事を雑に説明

もともと Launchable という会社を立ち上げて作っていたプロダクトが CloudBees に買収されて、今は CloudBees Smart Tests という名前になっています。

やっていることを一言で言うと、「AIによるソフトウェアテストの最適化」です。 CIとかで実行された膨大なテスト結果を学習して、コードを書いたときに「今回はこのテストとこのテストだけ実行しておけばOKですよ」というのを賢く教えてくれます。 これを使えば、PRのときは最小限のテストで爆速で回して、デプロイ直前だけフルで実行する、みたいな運用がスマートにできる。

あとは、テスト結果のデータを溜めているので、「どのテストが不安定(Flaky)か」とか、「複数のテストが落ちてるけど、原因はこれ一個(ミドルウェアの接続エラーとか)だよ」といった情報も出せます。 テスト結果というデータを元にAIなどを駆使して開発体験を良くしようぜ、っていうのが今の僕らの主戦場です。

これはホント褒めてほしくて書いちゃうんだけど、僕らがやってるサービスがInfoWorldのTesting部門でアワードとったのよ!!! 褒めて欲しいでみんなも広めてくれ!!!! www.infoworld.com/article/4104...

— Yoshiori (@yoshiori.bsky.social) 2025-12-16T07:19:34.122Z

bsky.app

買収されて1年半、今の立ち位置

会社が買収されてから1年半くらい経ちました。 個人的な感想ですが、CloudBeesは今めちゃくちゃAIに力を入れたがっていて、そのために僕らを買ったはずなので、社内でもかなり重要なチームとして扱われています。 全社ミーティングでもよく名前が出るし、セールスチームの最重要プロダクトの一つになっていたりもする。そのへんも含めて、なぜ「今」が面白いのかを書いてみます。

世界規模の検証データにビビる

Launchableの時みたいな「0→1」のフェーズも刺激的でしたが、ある程度の規模の会社で重要プロダクトをゴリゴリ開発するのもやっぱり面白いです。 。特にCloudBeesのセールスの人たちはエンタープライズに強いので、顧客が誰もが知る「世界的企業」だったりします。

例えばある超大企業は導入検討のために、僕らでもやらないようなコストをかけて徹底的に検証してくれたんですね。 結果、「検証の結果、とても良い性能が出ているので導入したい」と言ってもらえた。 それ自体も嬉しいんだけど、それ以上に「うわ、俺らの作ったやつ、こんな大規模な環境でこんな成果出るんだ……!」って驚いたりしました。

(本当は僕らがそれらの大規模調査データを作るべきなんだろうけど小さいチームだしそこまでお金ないので大企業の検証チームに徹底的に検証してもらえて嬉しかった)

そういった世界的企業と仕事をするのはデータの規模も桁違いだし楽しいです。

生成AIブームと「AI予算」のリアルな話

ご存知の通り最近の Agentic AI coding の流れは本当に凄くて、世の中にコードが生成される速度が爆上がりしています。 そうなると当然、動かすべきテストの量も爆発的に増える。結果として、僕たちのサービスの必要性も勝手に上がっていくという状況です。

あと、これはかなりぶっちゃけた話ですが、今ってどこの会社も「AI活用予算」が潤沢なんですよね。 株主や投資家への手前もあってか、普通のSaaSよりもお金を払ってもらいやすいという現実がある。

今、一番熱い領域で、ちゃんとお金を稼ぎながら開発できるのは、やっぱり楽しいです。

「三単現=麻雀の役?」レベルからの英語生活

5年前の僕は「三単現? 麻雀の役だっけ?」くらいの英語力でしたが、気がつけば毎日英語で仕事をして、レポートラインも上下ともに英語です。 チームにはインド、スリランカ、オーストラリア……といろんな国のメンバーがいますが、ネイティブじゃない人も多いので、お互い「優しい気持ち」でコミュニケーションしています。この前、忘年会的なのやったけど英語で飲み会するのにも気がつけば慣れていました。

こういう話をするともう英語できるから翻訳とか使わないでしょ?とか思われるかも知れないけど、文章書いたりとか読んだりとかはガンガンAIをつかってます。

僕くらいの英語力でも、他のチームのリーダー陣や人事と渡り合って仕事は回せる。これは、これから海外に挑戦したい人にとっても、だいぶ心強い話なんじゃないかと思います。(もちろん、まだまだなんだけど)

毎日英語に揉まれるなかで、少しずつ「できること」が増えていく感覚も純粋に楽しいですよ。

というわけで、一緒に働きませんか?

これ、USの会社あるあるなんですけど、採用は「スロット(枠)」があるかどうかがすべてなんですよね。どんなに優秀な人が来ても、枠がなければ採用できない。

で、今うちのチームには「1個」だけスロットがあります。 本当のことを言うと、この枠、表向きは「インドでの採用」として募集されているんです。 募集要項(Sr. Software Engineer AI - Full Stack)

でも、ボスと「日本でいい人見つけたら、強引にこの枠を日本側にネジ込もうぜ」という話をしています。

興味がある人、とりあえず最近の現場の話を聞いてみたいという人は、気軽に連絡ください! 連絡先知らない人は yoshiori@gmail.com にメールくれればOKです。 (※Xはもう見てないので、XのDM以外でお願いします!)

最近声日記ってやつをやっています

最近、声日記ってやつをはじめてます!

listen.style

もともとは id:ninjinkun と話してるときに「最近、声日記やってるんですよー」っていっていて、なんかブログより手軽に出来て日々のちょっとしたこととか話せていいですよ。って言われたのではじめてみたかんじです。

で、「とりあえずページネーションが出るようになるくらいまで投稿溜まったらブログにも書こう」って思ってたのですが、今日とうとうページネーションが出現したので宣伝してみます。

僕はどんなスタイルでやってるかっていうと

  • 使うサービスはにんじんくんに教えてもらったまま LISTEN を使う
  • ブラウザからそのまま撮って出し(編集とかしない)
  • 話す時間は3分目安(大幅に超えることも)

ってやってるんですね。で、これはなんとなくそうやってはじめたんだけどすごく良いなぁと思っていてアウトプットのための敷居が僕の中でだいぶ下がったんですよね。 なんかちょっとブログに書こうかなとか思っててもブログに書こうとするとそれなりに時間かかるので躊躇しちゃうんですよね。 たとえばこれなんかも書き始めてからもう10分くらい経ってる。こんな雑な文章でも10分くらい経ってる。

でも3分の声日記だとタイトルとか概要とか書いても5分で終わる。リモートで仕事してるから目の前にマイクあるのでホントに LISTENのページ開いてすぐに録音初めてタイトルと概要書いて終わり。 最初はポッドキャストとか声日記的なのはハードル高いと思ってたんだけど逆だったんですよね。サクッと公開できる。(これはLISTENっていうサービスが良いというのもあると思います)

5分で終わるってなると、仕事一旦終わらして晩御飯作る前にとかにサクッと公開出来るんですよね。しかもなんかタイトルに「日記」って付けたので雑に日記的なことを言えばいいだけっていう。そんなことをやってアウトプットする筋肉を取り戻そうとやっています。そんなアウトプット筋の話はこちら

listen.style

せっかくページネーションでるまでやってみたのでしばらく続けようと思います。楽だしね!!!

真・WEB+DB PRESS総集編的なやつにエッセイを寄稿させてもらった!

みんな大好き「WEB+DB PRESS」が隔月刊誌としては休刊するということで、真の総集編的な全部入り総集編が出ました。

DVDとダウンロードコード付き!!! 全記事PDF+全文検索っていう意味がわからないほど豪華なのに3000円っていう実質無料な感じなのでみんな買うと良いと思います。

で、僕もそこに寄稿させてもらいました。で、そのときの依頼メールに「エッセイを執筆してくれ」って書いてあって興奮してしまいました。

 

だって、エッセイですよ。エッセイ。好きなんですよ、エッセイ。

で、興奮してエッセイについての思いを導入に書き始めたら全然ページに収まらなくなってしまって結局削りました。

今読むと「これ、完全に自己満だから消しておいてよかったな」と思うんですがせっかくなのでここに載せて供養しようと思います。

エッセイを書くということの意味

この原稿は「Web開発者として生きるあなたへという題材でエッセイを書いてください」という依頼から書くことになった。エッセイである。僕の中でエッセイというのは小説家が自分の言葉で日常に感じたことやちょっとしたことを書くものの印象が強い。普段物語を読んでいるときには見えなかった作者の個人的な側面が見えてちょっとしたファン心理的にも楽しい読み物だった。特に若い頃は椎名誠のエッセイが大好きで沢山読んでいた記憶がある。その出会いを少しだけ語ると中学生のころに遡る。当時椎名誠は『岳物語』が教科書にのっていてお堅い人だと思っていた。まだエッセイとかあんまり読んだことがなかったある日、本屋さんで『かつをぶしの時代なのだ』というゆるーいタイトルの本を手にとったら作者がそのお堅い人だと思っていた椎名誠なので興味を持った。そこから椎名誠のエッセイにハマって他の人のも読んだりしたが僕の中ではエッセイといえば椎名誠なのである。そしてエッセイというのはそういう著者が自分の言葉で書くものなのだという概念から逃げられなかった。逃げられなかったので今、執筆依頼時に『本文は原則「ですます調」を想定していますが、「である調」のほうがよければその形でも大丈夫です。』と言われたのにガン無視して口語体で書いている。
人生の中でまさか自分が「エッセイを書いてくれ」なんて依頼を受けれるとは思っていなかったのでせっかくのチャンスだし楽しんで僕の中にある「エッセイっぽい文体」で書かせてもらおうと思っている。そう、そしてこうやって前置きをグダグダ書いてなかなか本題に入らないのもエッセイっぽいと思ってしまうのだ。

みなさん買いましょう!!

隔月刊誌としては休刊ってことなんだけど、なんか売上良ければちょいちょい出るんじゃないかなぁとか勝手に期待してるんですよね!!!!なんでみんな買いましょう!!!!

そして記事をChatGPTとかに学ばせて質問すれば最高な体験が出来るかも!!!(適当)

 

WEB+DB PRESS総集編[Vol.1~136] (WEB+DB PRESS plusシリーズ)

オンラインミーティングが始まったら自動で点灯するオンエアーネオンライト作った

家で仕事するようになったときからずっとオンラインミーティング始まったら自動で点灯するネオンサインみたいなのあったら良いなぁと思ってた。 まぁぼんやり思ってるだけだったんだけど、ちょっとやる気が出たのでガッと組んでみた(確定申告の書類集めとかしてるとそうなるよねー)

雑要件定義

  • 絶対に自動でON/OFF
  • 取り回しのためPCとは直接有線で繋げない
  • ミーティングはZoomだったりGoogle Meetだったりあるのである程度汎用的な方法

ミーティングの自動判別

たまにネットで出てくる記事でよく使われているZoomアプリのプロセス監視するのはGoogle Meetに対応できないので無し。カレンダー監視してミーティングの時間とか考えたけど、精度低いし突発の仕様相談とかに対応できないので無し。

結局仕事のミーティングはすべてカメラONにして行っているのでカメラの監視をすることにした。たまにゲームでDiscordつかって音声だけのときもあるけど、奥さん的要件としてはカメラに映り込みたくないというのが大きのでカメラの監視をすることにした。

で、カメラがつながっているかどうかではなく、実際に起動しているかどうかの監視はlsofコマンドで行える。

# lsof /dev/video0 
COMMAND    PID     USER FD   TYPE DEVICE SIZE/OFF NODE NAME
pipewire  2019 yoshiori 52u   CHR   81,0      0t0 2289 /dev/video0
wireplumb 2020 yoshiori 47u   CHR   81,0      0t0 2289 /dev/video0

linuxだとこんな感じ。(pipewireとかwireplumbはWayland下でオーディオデバイス扱うやつなので無視して大丈夫) 多分Macでも似たようなもんだと思う。

あとはこれをループで監視すればON/OFFが判別できる。

ライトのオンオフをリモートで操作

PCで会議のイベントは取れるようになったのであとはプログラムからネオンライトを操作できれば良い。昔ならIFTTTとか使おうとか考えたんだけど、今やIFTTTのwebhookは高級有料プランになってしまったので無し。スマートデバイス、PCから扱うのはじつは面倒くさいんだよなぁとか思いつつ、なんか他に無いかなぁと思ってたらSwitchBotはAPIを公開してた!マジでAPI公開してる企業は政府から補助出てほしい!!ありがとうSwitchBot!!

で、Rubyのgemもあったのでめっちゃあっさりできた。

    class Light
      def client
        @client ||= Switchbot::Client.new(ENV["TOKEN"], ENV["SECRET"])
      end

      def on
        client.device(ENV["DEVICE_ID"]).on
      end

      def off
        client.device(ENV["DEVICE_ID"]).off
      end
    end

これで道具は揃ったのであとは作るだけ。

材料

ON AIR ネオンライト

物理スイッチなので本体のスイッチはいれっぱなしにしてスマートプラグでON/OFF操作できる。あと電池も対応しているけど有線でも使えて接続口がUSB-Cなのも地味に嬉しい。

スマートプラグ

商品説明にはAPIについて全く触れられていない。なぜなのか!?(マニアックだから?)

【Option】薄型 USB-C ケーブル

USB-C のコネクタ側が薄型のL字なので接続時に綺麗に見える。初代Apple Pencilの突き刺さりが美しいと思った人はいらないと思う。

プログラム

github.com

ということで前回かいた「超楽にRubyで雑に書いたスクリプトをsystemdで管理したい! - 宇宙行きたい」はこれを動かすためでした。

まとめ

SwitchBotが実はAPI公開しててさくっと色々なハックが出来るのはもっともっと知られてほしい!!!!最高便利!ありがとう!!!