RP 2040でMac miniのスリープLEDスイッチを作った


昔のMac miniはスリープ状態がインジケータランプで判別できました

https://support.apple.com/ja-jp/102768

> 2014 年以前に発売されたモデルの Mac では、スリープ状態の間はステータスインジケータランプがゆっくりと点滅します

しかし最近のMac miniはスリープ状態が判別できません[^1]

M4 Mac mini本体裏の電源ボタンが話題になりました。熟練MacユーザやAppleからのメッセージとして 「Macは電源OFFせずスリープ運用せよ」 がありますが、スリープ運用するならMac mini本体LEDでスリープ状態が分かるようになっていて欲しいです[^2]

# Macbookは分かりやすい

MacBookは「画面を閉じた状態」がスリープ状態を物理的に示しており、
閉じればスリープ、開ければスリープ解除で非常に明確です。

Mac mini運用においてもこのような明確さが欲しい

# M4 -Mac miniを1ヶ月使って気になったこと

- キーボードでスリープ状態にするとMac miniがスリープから即復帰することがある
- 電源ボタンによるスリープ移行は安定するが本体裏で不便
- スリープ中もUSB電源供給されるのでUSB機器構成によってはかなりの待機電力になる
- ThunderboltのKVM切替時にMac miniがスリープ復帰する
　→KVMを別PCに切替えており「スリープ復帰してること」に気づく手段がない

# 欲しい機能

- アクティブ状態、スリープ状態がLEDで分かる
- スイッチOFFでスリープ状態にする
- スイッチONでスリープ状態から復帰させる
- スイッチOFFの場合はスリープ状態から勝手に復帰しても再度スリープさせる

:::note warn
一番最後の要件はMac miniに悪い影響を及ぼす可能性もあると思います
:::

# 実際の動作の様子
![PXL_20241208_103232166.TS~3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3955772/4bf212e9-1f22-8157-f335-caeccb82814c.gif)

# マイコンはRP2040、言語はCircuitPythonを採用

当初はQMKのsuspend_power_down_userとsuspend_wakeup_init_userを使ってPC側のスリープ、スリープ復帰の処理を作っていましたが、QMKはPCスリープ中にマイコン側の処理を行うことが困難なため最終的にCircuitPythonを採用しました。

python言語はよく分からないので今回はCopilotとClaudeにプログラミングを丸投げしました。IDEはThonny。AIが生成したコードをコピペして即動作できるThonnyはとても便利でした[^3]


## code.py
```python
import time
import board
import digitalio
import neopixel
import usb_hid
import supervisor
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode

# 定数の宣言
NUM_PIXELS               = 1           # LEDの数
PIN_NUM                  = board.GP23  # LEDが接続されているピン番号
ACTIVE_BRIGHTNESS        = 0.05        # PC起動中の明るさ
SLEEP_BRIGHTNESS         = 0.02        # スリープ中の明るさ
DIMMED_BRIGHTNESS        = 0.01        # ディムモードの明るさ

# 呼吸エフェクトの設定
BREATHING_STEPS          = 200         # 呼吸エフェクトのステップ数
BREATHING_INTERVAL       = 4.0         # 呼吸エフェクトの間隔（秒）
BREATHING_DARK_INTERVAL  = 5.0         # 呼吸エフェクトの暗い状態の間隔（秒）
BREATHING_CYCLE_LIMIT    = 3           # 呼吸エフェクトのサイクル制限

# 状態遷移の設定
TRANSITION_DURATION      = 5.0         # 状態遷移の期間（秒）
SLEEP_GUARD_DURATION     = 60          # スリープガードの期間（秒）

# デバイスの初期化
kbd = Keyboard(usb_hid.devices)  # キーボードデバイスの初期化
pixels = neopixel.NeoPixel(PIN_NUM, NUM_PIXELS, auto_write=False)  # NeoPixel LEDの初期化
switch_pin = digitalio.DigitalInOut(board.GP6)  # スイッチピンの設定
switch_pin.direction = digitalio.Direction.INPUT  # スイッチピンを入力モードに設定
switch_pin.pull = digitalio.Pull.UP  # スイッチピンにプルアップ抵抗を設定

# グローバル変数
last_sleep_shortcut_time = 0  # 最後にスリープショートカットが押された時間
previous_switch_state = True  # 前回のスイッチ状態

# LED制御関数
def pixels_color(r, g, b, brightness):
    pixels.fill((int(r * brightness), int(g * brightness), int(b * brightness)))
    pixels.show()

def gradient_effect(start_color, end_color, steps, duration):
    r1, g1, b1 = start_color
    r2, g2, b2 = end_color
    step_delay = duration / steps
    
    for i in range(steps):
        r = r1 + (r2 - r1) * i // steps
        g = g1 + (g2 - g1) * i // steps
        b = b1 + (b2 - b1) * i // steps
        pixels_color(r, g, b, ACTIVE_BRIGHTNESS)
        time.sleep(step_delay)

def flash_led(is_sleep, duration, steps):
    start_color = (0, 255, 0) if is_sleep else (255, 0, 0)
    end_color = (255, 0, 0) if is_sleep else (0, 255, 0)
    gradient_effect(start_color, end_color, steps, duration)

# 呼吸エフェクト関数
def execute_steps(num_steps, step_delay, callback, sleep=True):
    for i in range(num_steps):
        callback(i)
        if switch_pin.value:
            print("[DEBUG] Switch state changed during steps.")
            return
        if sleep:
            time.sleep(step_delay)

def breathing_effect(r, g, b, brightness, interval, dark_interval):
    steps = BREATHING_STEPS
    step_delay = interval / (steps * 2)
    
    # 上昇フェーズ
    execute_steps(steps, step_delay, 
                 lambda i: pixels_color(r, g, b, (i * brightness) / steps), 
                 sleep=False)

    # 下降フェーズ
    execute_steps(steps, step_delay, 
                 lambda i: pixels_color(r, g, b, ((steps - i) * brightness) / steps), 
                 sleep=False)

    # 消灯時間
    execute_steps(int(dark_interval / step_delay), step_delay, lambda _: None)

# キーボード制御関数
def send_key_press(*keys):
    print(f"[DEBUG] Sending keys: {keys}")
    pixels_color(255, 0, 0, ACTIVE_BRIGHTNESS)
    kbd.press(*keys)
    kbd.release_all()

def check_switch_state():
    global previous_switch_state, last_sleep_shortcut_time

    current_time = time.monotonic()

    if not switch_pin.value and previous_switch_state:
        send_key_press(Keycode.ALT, Keycode.COMMAND, Keycode.POWER)
        previous_switch_state = False
        last_sleep_shortcut_time = current_time
        flash_led(True, TRANSITION_DURATION, 50)
        return True

    if switch_pin.value and not previous_switch_state:
        send_key_press(Keycode.ALT)
        previous_switch_state = True
        flash_led(False, TRANSITION_DURATION, 50)
        return True
        
    return False

def update_led_state(is_active):
    if is_active:
        if switch_pin.value:
            pixels_color(0, 255, 0, ACTIVE_BRIGHTNESS)  # 緑
        else:
            pixels_color(255, 0, 0, ACTIVE_BRIGHTNESS)  # 赤

def perform_breathing_effect():
    breathing_cycle_count = 0
    while not switch_pin.value and breathing_cycle_count < BREATHING_CYCLE_LIMIT:
        current_brightness = SLEEP_BRIGHTNESS if breathing_cycle_count < BREATHING_CYCLE_LIMIT else DIMMED_BRIGHTNESS
        breathing_effect(255, 255, 255, current_brightness, BREATHING_INTERVAL, BREATHING_DARK_INTERVAL)
        breathing_cycle_count += 1

def handle_switch_and_led():
    global last_sleep_shortcut_time

    is_active = supervisor.runtime.usb_connected

    if not check_switch_state():
        update_led_state(is_active)

        if is_active:
            current_time = time.monotonic()
            if (current_time - last_sleep_shortcut_time > SLEEP_GUARD_DURATION and 
                not switch_pin.value):
                time.sleep(TRANSITION_DURATION)
                send_key_press(Keycode.ALT, Keycode.COMMAND, Keycode.POWER)
                last_sleep_shortcut_time = current_time
                flash_led(True, TRANSITION_DURATION, 50)
        else:
            perform_breathing_effect()

def handle_error(e):
    print(f"[ERROR] An error occurred: {type(e).__name__}: {str(e)}")
    pixels_color(0, 0, 0, ACTIVE_BRIGHTNESS)
    time.sleep(1)

def main():
    global previous_switch_state
    
    previous_switch_state = True
    
    while True:
        try:
            handle_switch_and_led()
        except Exception as e:
            handle_error(e)
        time.sleep(0.1)

if __name__ == "__main__":
    main()
```

もちろん上記のコードが一発でAIから出力された訳ではありません。

# AIでプログラムした手順

最初の依頼文章
```
RP2040互換ボードを使いCircuitPythonでPCのスリープ状態を検出し、
LEDを光らせるプログラムを書いてください。
```
pythonのプログラムが生成されるのでThonnyに貼り付けて実行する。
エラーが出たらAIにエラー内容をコピペで丸投げすると修正してくれるので、
再度Thonnyに貼り付けて実行、これを繰り返して動くものを作る。

今回のプログラム程度なら全コードをコピペでAIに伝えることが出来るので、
```
import time
import board

...(中略)...

if __name__ == "__main__":
    main()
----------
上記はRP2040ボードをCircuitPythonで作ったプログラムです。
スリープショートカットを送信する際にLEDを緑色から赤色にグラデーションで変化させてください
```
のような指示を繰り返して要件のすべてを自然言語指示でプログラム完成させました。

Claudeは賢いのですが無料だと利用制限があるので、大枠をCopilotにコーディングしてもらって、詰まった場合はClaudeに聞くやり方で進めました。人間に対してやったら嫌がられそうですね。

# 上記CircuitPythonプログラムに必要なライブラリ

## adafruit_hidライブラリ

https://github.com/adafruit/Adafruit_CircuitPython_HID

「Code」→「Download ZIP」で得られたZIP内の「adafruit_hid」フォルダを、
RP2040デバイス「CIRCUITPY」の「lib」フォルダにコピーする。

## neopixelライブラリ

https://github.com/adafruit/Adafruit_CircuitPython_NeoPixel/tree/main

「Code」→「Download ZIP」で得られたZIP内の「neopixel.py」ファイルを、
RP2040デバイス「CIRCUITPY」の「lib」フォルダにコピーする。

## 

![スクリーンショット 2024-12-08 16.40.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3955772/afd639a6-56c7-90ac-7435-d86c28c9e612.png)

![スクリーンショット 2024-12-08 16.40.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3955772/4fbe3ea5-73bf-7fa6-797d-a27ce69546c2.png)

# 今回使ったRP2040ボード

- YD-RP2040

https://www.google.com/search?q=YD-RP2040&sourceid=chrome&ie=UTF-8

Aliexpressなどで安く買えるUSB-CのRP2040ボード。
フルカラーLEDのWS2812が表面実装

![PXL_20241208_093549053.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3955772/3c034982-c42b-8562-f1c3-48a408112b30.jpeg)

[^1]:　Macのステータスインジケータランプは異常時には点滅したりするそうなので、スリープの際に点滅したりすると異常時と紛らわしいので点滅をやめたのかもしれないと思いました。

[^2]: M4 Mac miniはKVMでディスプレイとUSBを切断すれば、youtubeで音を出した状態で3W前後の消費電力で驚きました。スリープする必要すらないのが正解なのかもしれません。

[^3]:VSCodeのCircuitPythonプラグインも便利ですが、今回のようにほとんどコーティングせずコピペして実行を繰り返す場合はTonnyの方が楽でした。