1. TanaRadio
  2. 376 日記 | 棚選択ボタンとLED..
376 日記 | 棚選択ボタンとLEDランプを追加
2026-07-06 26:21

376 日記 | 棚選択ボタンとLEDランプを追加

spotify apple_podcasts

ボタンで棚を選択できるようにしました。選んでいる棚番号はLEDランプの点滅回数で表示します(フェーズ5F完了)。

# TanaRadio Pi フェーズ5F 詳細マニュアル

## 棚選びボタンとLED1を追加する

## 0. 今回の目標

フェーズ5Eでは、棚ファイルを指定してプレイリストを作り、mpvで再生できるようになりました。

フェーズ5Fでは、その「棚の切り替え」を物理ボタンで行えるようにします。

今回の動きは次の通りです。

```text
棚選びボタンを押す
↓
棚番号が1つ進む
↓
LED1が棚番号ぶん点滅する
↓
選ばれた棚ファイルから playlist.m3u を作る
↓
mpvをその棚で再生し直す
```

今回はまだ、再生モード選びボタンは追加しません。

再生モードは、5Eまでと同じく、

```text
過去3日分
古い順
```

を基本にします。

---

# 1. 今回使う棚構成

フェーズ5Fでは、フェーズ5Eで作った次の棚構成を使います。

```text
1: 声日記・生活 (voice) -> shelf_01_voice_life.txt
2: 技術・AI・ものづくり (tech) -> shelf_02_tech_ai_mono.txt
3: 教育・研究・知の実践 (edu) -> shelf_03_education_research.txt
4: 本・文化・物語 (books) -> shelf_04_books_culture.txt
5: TanaRadio (tana) -> shelf_05_tanaradio.txt
```

棚選びボタンを押すと、この順番で進みます。

```text
声日記・生活
↓
技術・AI・ものづくり
↓
教育・研究・知の実践
↓
本・文化・物語
↓
TanaRadio
↓
声日記・生活
```

LED1の点滅回数は、次の対応です。

```text
1回点滅:声日記・生活
2回点滅:技術・AI・ものづくり
3回点滅:教育・研究・知の実践
4回点滅:本・文化・物語
5回点滅:TanaRadio
```

---

# 2. 今回追加する部品

今回追加する部品は2つです。

```text
タクトスイッチ 1個
LED 1個
```

用途は次の通りです。

```text
タクトスイッチ:棚選びボタン
LED:棚番号表示LED
```

既存の3ボタンは、そのまま使います。

```text
再生/一時停止
戻る
次へ
```

つまり、フェーズ5F終了時点では、次の構成になります。

```text
ボタン1:再生/一時停止
ボタン2:戻る
ボタン3:次へ
ボタン4:棚選び

LED1:棚番号表示
```

---

# 3. GPIO番号

このマニュアルでは、次のGPIO番号を使います。

```text
再生/一時停止:GPIO17
戻る:GPIO27
次へ:GPIO22

棚選びボタン:GPIO23
棚番号LED:GPIO24
```

Raspberry Piの物理ピン番号では、次の対応です。

```text
GPIO17:物理ピン11
GPIO27:物理ピン13
GPIO22:物理ピン15
GPIO23:物理ピン16
GPIO24:物理ピン18

GND:物理ピン6, 9, 14, 20, 25 など
```

注意点です。

```text
GPIO番号と物理ピン番号は違います。
```

コードでは「GPIO番号」を使います。

たとえば、

```text
GPIO23
```

は、物理ピン番号では、

```text
16番ピン
```

です。

ここを取り違えると、ボタンを押しても無反応になります。静かな失敗です。電子工作ではいちばん地味に困るやつです。

---

# 4. 電源を切る

配線を触る前に、必ずRaspberry Piを終了します。

ターミナルで次を実行します。

```bash
sudo shutdown -h now
```

完全に電源が切れてから、USB-C電源を抜きます。

---

# 5. 棚選びボタンを配線する

棚選びボタンは、次のようにつなぎます。

```text
GPIO23 ─ 1kΩ抵抗 ─ タクトスイッチ ─ GND
```

この構成では、Python側で内部プルアップを使います。

そのため、動作は次のようになります。

```text
ボタンを押していない:HIGH
ボタンを押した:LOW
```

これまでのボタンと同じ考え方です。

---

# 6. 棚番号LEDを配線する

LEDは、次のようにつなぎます。

```text
GPIO24 ─ 330Ω抵抗 ─ LEDの長い足
LEDの短い足 ─ GND
```

LEDの足の目安です。

```text
長い足:プラス側
短い足:GND側
```

光らない場合は、まずLEDの向きを疑ってください。

LEDは、向きが逆だと基本的に光りません。
壊れたわけではなく、ただ黙っているだけです。

---

# 7. Raspberry Piを起動する

配線が終わったら、Raspberry Piの電源を入れます。

ターミナルを開き、作業フォルダに移動します。

```bash
cd ~/tanaradio5
```

現在地を確認します。

```bash
pwd
```

次のように表示されればOKです。

```text
/home/tana/tanaradio5
```

ユーザー名が違う場合は、そこだけ変わります。

---

# 8. 必要なパッケージを確認する

GPIOとmpvを使うため、念のため次を実行します。

```bash
sudo apt update
sudo apt install -y python3-gpiozero python3-lgpio mpv
```

すでに入っている場合は、そのままで大丈夫です。

---

# 9. 棚ファイルを確認する

今回使う棚ファイルがあるか確認します。

```bash
ls shelf_*.txt
```

次の5つが見えればOKです。

```text
shelf_01_voice_life.txt
shelf_02_tech_ai_mono.txt
shelf_03_education_research.txt
shelf_04_books_culture.txt
shelf_05_tanaradio.txt
```

それぞれの中身も確認します。

```bash
cat shelf_01_voice_life.txt
cat shelf_02_tech_ai_mono.txt
cat shelf_03_education_research.txt
cat shelf_04_books_culture.txt
cat shelf_05_tanaradio.txt
```

RSS URLが1行ずつ並んでいればOKです。

例です。

```text
https://listen.style/p/xxxx/rss
https://listen.style/p/yyyy/rss
https://listen.style/p/zzzz/rss
```

空の棚ファイルがある場合、その棚に切り替えたときにエラーになります。

テスト段階では、空の棚にも一時的に動作確認用のRSS URLを1本入れておくと安心です。

---

# 10. 5F用Pythonファイルを作る

新しいPythonファイルを作ります。

```bash
nano tanaradio_5f.py
```

次のコードをそのまま貼り付けます。

重要です。
最初の行は必ず、次で始めてください。

```text
#!/usr/bin/env python3
```

この前に、余計な文字を入れないでください。

```python
#!/usr/bin/env python3
import argparse
import json
import os
import signal
import socket
import subprocess
import time
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from threading import Lock

from gpiozero import Button, LED

# =========================
# GPIO設定
# 5Dまでと違うGPIOを使っていた場合は、ここだけ直してください。
# BCM番号です。物理ピン番号ではありません。
# =========================
PLAY_GPIO = 17       # 再生/一時停止ボタン
PREV_GPIO = 27       # 戻るボタン
NEXT_GPIO = 22       # 次へボタン
SHELF_GPIO = 23      # 棚選びボタン(今回追加)
SHELF_LED_GPIO = 24  # 棚番号表示LED(今回追加)

# =========================
# ファイル設定
# =========================
BASE_DIR = Path(__file__).resolve().parent
PLAYLIST_FILE = BASE_DIR / "playlist.m3u"
STATE_FILE = BASE_DIR / "state_5f.json"
MPV_SOCKET = "/tmp/tanaradio_5f_mpv.sock"

# フェーズ5Fでは、まだ再生モードボタンは追加しません。
# 5Eまでと同じく「過去3日分・古い順」を基本にします。
DAYS_BACK = 3

# =========================
# 棚設定
# フェーズ5Eで作った棚構成に合わせています。
# =========================
SHELVES = [
    {
        "label": "声日記・生活",
        "short": "voice",
        "file": "shelf_01_voice_life.txt",
    },
    {
        "label": "技術・AI・ものづくり",
        "short": "tech",
        "file": "shelf_02_tech_ai_mono.txt",
    },
    {
        "label": "教育・研究・知の実践",
        "short": "edu",
        "file": "shelf_03_education_research.txt",
    },
    {
        "label": "本・文化・物語",
        "short": "books",
        "file": "shelf_04_books_culture.txt",
    },
    {
        "label": "TanaRadio",
        "short": "tana",
        "file": "shelf_05_tanaradio.txt",
    },
]


@dataclass
class Episode:
    title: str
    audio_url: str
    pub_date: datetime | None
    feed_title: str


mpv_process: subprocess.Popen | None = None
current_shelf_index = 0
shelf_lock = Lock()
shelf_led: LED | None = None


def read_lines(path: Path) -> list[str]:
    if not path.exists():
        return []

    lines = []
    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line:
            continue
        if line.startswith("#"):
            continue
        lines.append(line)

    return lines


def load_state() -> int:
    if not STATE_FILE.exists():
        return 0

    try:
        data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
        index = int(data.get("shelf_index", 0))
        if 0 <= index < len(SHELVES):
            return index
    except Exception:
        pass

    return 0


def save_state(index: int) -> None:
    data = {
        "shelf_index": index,
        "shelf_label": SHELVES[index]["label"],
        "shelf_short": SHELVES[index]["short"],
        "shelf_file": SHELVES[index]["file"],
        "saved_at": datetime.now().isoformat(timespec="seconds"),
    }

    STATE_FILE.write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


def download_rss(url: str) -> bytes:
    request = urllib.request.Request(
        url,
        headers={"User-Agent": "TanaRadio-Pi/5F"},
    )

    with urllib.request.urlopen(request, timeout=25) as response:
        return response.read()


def get_child_text(parent: ET.Element, tag_name: str) -> str:
    element = parent.find(tag_name)
    if element is not None and element.text:
        return element.text.strip()
    return ""


def find_audio_url(item: ET.Element) -> str | None:
    # 一般的なRSS Podcastのenclosureタグ
    enclosure = item.find("enclosure")
    if enclosure is not None:
        audio_url = enclosure.attrib.get("url")
        if audio_url:
            return audio_url

    # media:content など、名前空間つきタグにも軽く対応
    for child in item:
        if child.tag.endswith("content"):
            audio_url = child.attrib.get("url")
            if audio_url:
                return audio_url

    return None


def parse_pub_date(text: str) -> datetime | None:
    if not text:
        return None

    try:
        dt = parsedate_to_datetime(text)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt.astimezone()
    except Exception:
        return None


def parse_rss(rss_data: bytes, feed_url: str) -> list[Episode]:
    root = ET.fromstring(rss_data)
    channel = root.find("channel")

    if channel is None:
        raise ValueError("RSSのchannelが見つかりません。")

    feed_title = get_child_text(channel, "title") or feed_url
    episodes: list[Episode] = []

    for item in channel.findall("item"):
        audio_url = find_audio_url(item)
        if not audio_url:
            continue

        title = get_child_text(item, "title") or "タイトルなし"
        pub_date = parse_pub_date(get_child_text(item, "pubDate"))

        episodes.append(
            Episode(
                title=title,
                audio_url=audio_url,
                pub_date=pub_date,
                feed_title=feed_title,
            )
        )

    return episodes


def collect_episodes_for_shelf(index: int) -> list[Episode]:
    shelf = SHELVES[index]
    shelf_path = BASE_DIR / shelf["file"]
    feed_urls = read_lines(shelf_path)

    if not feed_urls:
        raise ValueError(f"{shelf_path.name} にRSS URLがありません。")

    print()
    print(f"棚 {index + 1}: {shelf['label']} ({shelf['short']})")
    print(f"棚ファイル: {shelf_path.name}")
    print(f"RSS数: {len(feed_urls)}")

    all_episodes: list[Episode] = []

    for feed_url in feed_urls:
        print(f"RSS取得中: {feed_url}")

        try:
            rss_data = download_rss(feed_url)
            episodes = parse_rss(rss_data, feed_url)
            all_episodes.extend(episodes)
            print(f"  取得: {len(episodes)} 件")
        except Exception as e:
            print(f"  このRSSは読み飛ばしました: {e}")

    if not all_episodes:
        raise ValueError("音声エピソードを1件も取得できませんでした。")

    now = datetime.now().astimezone()
    cutoff = now - timedelta(days=DAYS_BACK)

    filtered = [
        ep for ep in all_episodes
        if ep.pub_date is not None and ep.pub_date >= cutoff
    ]

    if not filtered:
        print(f"過去{DAYS_BACK}日分のエピソードがありません。")
        print("動作確認しやすいように、今回は最新10件でプレイリストを作ります。")

        filtered = sorted(
            all_episodes,
            key=lambda ep: ep.pub_date or datetime.min.replace(tzinfo=timezone.utc),
            reverse=True,
        )[:10]

        filtered.sort(
            key=lambda ep: ep.pub_date or datetime.min.replace(tzinfo=timezone.utc)
        )
    else:
        filtered.sort(
            key=lambda ep: ep.pub_date or datetime.min.replace(tzinfo=timezone.utc)
        )

    return filtered


def make_playlist(index: int) -> int:
    episodes = collect_episodes_for_shelf(index)

    lines = ["#EXTM3U"]

    for ep in episodes:
        date_text = ep.pub_date.isoformat(timespec="minutes") if ep.pub_date else "日付不明"
        safe_title = ep.title.replace("\n", " ").strip()
        safe_feed = ep.feed_title.replace("\n", " ").strip()

        lines.append(f"# {date_text} / {safe_feed} / {safe_title}")
        lines.append(ep.audio_url)

    PLAYLIST_FILE.write_text(
        "\n".join(lines) + "\n",
        encoding="utf-8",
    )

    print(f"{PLAYLIST_FILE.name} を作成しました。")
    print(f"音声URL数: {len(episodes)} 件")

    return len(episodes)


def wait_for_mpv_socket(timeout: float = 5.0) -> bool:
    start = time.time()

    while time.time() - start < timeout:
        if os.path.exists(MPV_SOCKET):
            return True
        time.sleep(0.1)

    return False


def send_mpv_command(command: list[str | int | float]) -> None:
    if not os.path.exists(MPV_SOCKET):
        print("mpvのソケットが見つかりません。mpvがまだ起動していない可能性があります。")
        return

    payload = json.dumps({"command": command}) + "\n"

    try:
        with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
            client.connect(MPV_SOCKET)
            client.sendall(payload.encode("utf-8"))
    except Exception as e:
        print(f"mpvへの命令送信に失敗しました: {e}")


def stop_mpv() -> None:
    global mpv_process

    if mpv_process is None:
        return

    if mpv_process.poll() is None:
        try:
            send_mpv_command(["quit"])
            mpv_process.wait(timeout=3)
        except Exception:
            mpv_process.terminate()

            try:
                mpv_process.wait(timeout=3)
            except Exception:
                mpv_process.kill()

    mpv_process = None

    if os.path.exists(MPV_SOCKET):
        try:
            os.remove(MPV_SOCKET)
        except OSError:
            pass


def start_mpv() -> None:
    global mpv_process

    stop_mpv()

    if os.path.exists(MPV_SOCKET):
        os.remove(MPV_SOCKET)

    print("mpvを起動します。")

    mpv_process = subprocess.Popen(
        [
            "mpv",
            "--input-ipc-server=" + MPV_SOCKET,
            "--force-window=no",
            str(PLAYLIST_FILE),
        ]
    )

    if not wait_for_mpv_socket():
        print("mpvの操作ソケットを確認できませんでした。")
        print("再生自体は始まっている可能性があります。")


def blink_shelf_number(index: int) -> None:
    if shelf_led is None:
        return

    count = index + 1
    print(f"LED点滅: {count}回")

    for _ in range(count):
        shelf_led.on()
        time.sleep(0.18)
        shelf_led.off()
        time.sleep(0.18)

    time.sleep(0.25)


def blink_error() -> None:
    if shelf_led is None:
        return

    for _ in range(6):
        shelf_led.on()
        time.sleep(0.07)
        shelf_led.off()
        time.sleep(0.07)


def select_shelf(index: int, restart_player: bool = True) -> bool:
    global current_shelf_index

    with shelf_lock:
        try:
            make_playlist(index)
            current_shelf_index = index
            save_state(index)

            blink_shelf_number(index)

            if restart_player:
                start_mpv()

            print(f"現在の棚: {index + 1} / {SHELVES[index]['label']} ({SHELVES[index]['short']})")
            return True

        except Exception as e:
            print("棚の切り替えに失敗しました。")
            print(e)
            blink_error()
            return False


def next_shelf() -> None:
    next_index = (current_shelf_index + 1) % len(SHELVES)

    print()
    print(f"棚ボタン: {current_shelf_index + 1} → {next_index + 1}")
    print(f"次の棚: {SHELVES[next_index]['label']} ({SHELVES[next_index]['short']})")

    select_shelf(next_index, restart_player=True)


def on_play() -> None:
    print("再生/一時停止")
    send_mpv_command(["cycle", "pause"])


def on_prev() -> None:
    print("戻る")
    send_mpv_command(["playlist-prev"])


def on_next() -> None:
    print("次へ")
    send_mpv_command(["playlist-next"])


def list_shelves() -> None:
    print("棚一覧")

    for i, shelf in enumerate(SHELVES, start=1):
        path = BASE_DIR / shelf["file"]
        urls = read_lines(path)

        print(
            f"{i}: {shelf['label']} ({shelf['short']}) "
            f"-> {path.name}  RSS数: {len(urls)}"
        )


def test_led() -> None:
    global shelf_led

    shelf_led = LED(SHELF_LED_GPIO)

    try:
        for index, shelf in enumerate(SHELVES):
            print(f"棚{index + 1}: {shelf['label']} ({shelf['short']}) の点滅テスト")
            blink_shelf_number(index)
            time.sleep(0.5)
    finally:
        shelf_led.off()
        shelf_led.close()
        shelf_led = None


def main() -> None:
    global current_shelf_index, shelf_led

    parser = argparse.ArgumentParser(description="TanaRadio Pi フェーズ5F")
    parser.add_argument("--list", action="store_true", help="棚一覧を表示する")
    parser.add_argument("--test-led", action="store_true", help="LEDの点滅をテストする")
    args = parser.parse_args()

    if args.list:
        list_shelves()
        return

    if args.test_led:
        test_led()
        return

    current_shelf_index = load_state()

    print("TanaRadio Pi フェーズ5Fを起動します。")
    print()

    list_shelves()

    print()
    print(
        f"前回の棚: {current_shelf_index + 1} / "
        f"{SHELVES[current_shelf_index]['label']} "
        f"({SHELVES[current_shelf_index]['short']})"
    )

    shelf_led = LED(SHELF_LED_GPIO)

    play_button = Button(PLAY_GPIO, pull_up=True, bounce_time=0.08)
    prev_button = Button(PREV_GPIO, pull_up=True, bounce_time=0.08)
    next_button = Button(NEXT_GPIO, pull_up=True, bounce_time=0.08)
    shelf_button = Button(SHELF_GPIO, pull_up=True, bounce_time=0.12)

    play_button.when_pressed = on_play
    prev_button.when_pressed = on_prev
    next_button.when_pressed = on_next
    shelf_button.when_pressed = next_shelf

    try:
        ok = select_shelf(current_shelf_index, restart_player=True)

        if not ok:
            print("最初の棚を作れませんでした。棚ファイルを確認してください。")
            return

        print()
        print("操作できます。")
        print("再生/一時停止、戻る、次へ、棚選びボタンを押してください。")
        print("終了するには Ctrl + C を押します。")

        signal.pause()

    except KeyboardInterrupt:
        print()
        print("終了します。")

    finally:
        stop_mpv()

        if shelf_led is not None:
            shelf_led.off()
            shelf_led.close()

        play_button.close()
        prev_button.close()
        next_button.close()
        shelf_button.close()


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

保存します。

```text
Ctrl + O
Enter
Ctrl + X
```

---

# 11. 実行権限を付ける

次を実行します。

```bash
chmod +x tanaradio_5f.py
```

ただし、実行は `python3` で行うのが安全です。

```bash
python3 tanaradio_5f.py
```

以前のように、Pythonファイルをシェルスクリプトとして読んでしまう事故を避けるためです。

---

# 12. 棚一覧を確認する

まず、棚ファイルが正しく認識されているか確認します。

```bash
python3 tanaradio_5f.py --list
```

期待される表示例です。

```text
棚一覧
1: 声日記・生活 (voice) -> shelf_01_voice_life.txt  RSS数: 8
2: 技術・AI・ものづくり (tech) -> shelf_02_tech_ai_mono.txt  RSS数: 6
3: 教育・研究・知の実践 (edu) -> shelf_03_education_research.txt  RSS数: 5
4: 本・文化・物語 (books) -> shelf_04_books_culture.txt  RSS数: 4
5: TanaRadio (tana) -> shelf_05_tanaradio.txt  RSS数: 1
```

RSS数は実際のファイル内容によって変わります。

ここで重要なのは、次の対応が正しく出ることです。

```text
1: 声日記・生活 -> shelf_01_voice_life.txt
2: 技術・AI・ものづくり -> shelf_02_tech_ai_mono.txt
3: 教育・研究・知の実践 -> shelf_03_education_research.txt
4: 本・文化・物語 -> shelf_04_books_culture.txt
5: TanaRadio -> shelf_05_tanaradio.txt
```

---

# 13. LEDだけテストする

いきなり再生まで行かず、まずLEDの点滅を確認します。

```bash
python3 tanaradio_5f.py --test-led
```

期待される動きです。

```text
棚1:LEDが1回点滅
棚2:LEDが2回点滅
棚3:LEDが3回点滅
棚4:LEDが4回点滅
棚5:LEDが5回点滅
```

表示例です。

```text
棚1: 声日記・生活 (voice) の点滅テスト
LED点滅: 1回
棚2: 技術・AI・ものづくり (tech) の点滅テスト
LED点滅: 2回
棚3: 教育・研究・知の実践 (edu) の点滅テスト
LED点滅: 3回
棚4: 本・文化・物語 (books) の点滅テスト
LED点滅: 4回
棚5: TanaRadio (tana) の点滅テスト
LED点滅: 5回
```

LEDが光らない場合は、次を確認します。

```text
LEDの向きが逆ではないか
GPIO24ではなく別のピンにつないでいないか
GNDにつながっているか
抵抗を入れているか
```

---

# 14. 5Fを起動する

いよいよ5Fを起動します。

```bash
python3 tanaradio_5f.py
```

うまくいくと、次のような表示になります。

```text
TanaRadio Pi フェーズ5Fを起動します。

棚一覧
1: 声日記・生活 (voice) -> shelf_01_voice_life.txt  RSS数: 8
2: 技術・AI・ものづくり (tech) -> shelf_02_tech_ai_mono.txt  RSS数: 6
3: 教育・研究・知の実践 (edu) -> shelf_03_education_research.txt  RSS数: 5
4: 本・文化・物語 (books) -> shelf_04_books_culture.txt  RSS数: 4
5: TanaRadio (tana) -> shelf_05_tanaradio.txt  RSS数: 1

前回の棚: 1 / 声日記・生活 (voice)

棚 1: 声日記・生活 (voice)
棚ファイル: shelf_01_voice_life.txt
RSS数: 8
RSS取得中: ...
playlist.m3u を作成しました。
音声URL数: 10 件
LED点滅: 1回
mpvを起動します。
現在の棚: 1 / 声日記・生活 (voice)

操作できます。
再生/一時停止、戻る、次へ、棚選びボタンを押してください。
終了するには Ctrl + C を押します。
```

音が出れば、まず成功です。

---

# 15. 既存3ボタンを確認する

次のボタンを押します。

```text
再生/一時停止
戻る
次へ
```

ターミナルに次のように表示されれば、ボタン入力は読めています。

```text
再生/一時停止
戻る
次へ
```

音声もそれに合わせて動けばOKです。

---

# 16. 棚選びボタンを押す

棚選びボタンを1回押します。

棚1から棚2に進む場合、期待される動きは次の通りです。

```text
棚1:声日記・生活
↓
棚2:技術・AI・ものづくり
```

ターミナルには、たとえば次のように表示されます。

```text
棚ボタン: 1 → 2
次の棚: 技術・AI・ものづくり (tech)

棚 2: 技術・AI・ものづくり (tech)
棚ファイル: shelf_02_tech_ai_mono.txt
RSS数: 6
RSS取得中: ...
playlist.m3u を作成しました。
音声URL数: 12 件
LED点滅: 2回
mpvを起動します。
現在の棚: 2 / 技術・AI・ものづくり (tech)
```

さらに押すと、次のように進みます。

```text
2 → 3:教育・研究・知の実践
3 → 4:本・文化・物語
4 → 5:TanaRadio
5 → 1:声日記・生活
```

LED点滅の対応は次の通りです。

```text
1回点滅:声日記・生活
2回点滅:技術・AI・ものづくり
3回点滅:教育・研究・知の実践
4回点滅:本・文化・物語
5回点滅:TanaRadio
```

---

# 17. 前回の棚が保存されるか確認する

一度終了します。

```text
Ctrl + C
```

もう一度起動します。

```bash
python3 tanaradio_5f.py
```

前回最後に選んだ棚から始まれば成功です。

棚の状態は、次のファイルに保存されます。

```text
state_5f.json
```

中身を確認するには、次を実行します。

```bash
cat state_5f.json
```

表示例です。

```json
{
  "shelf_index": 1,
  "shelf_label": "技術・AI・ものづくり",
  "shelf_short": "tech",
  "shelf_file": "shelf_02_tech_ai_mono.txt",
  "saved_at": "2026-07-06T"
}
```

ここで `shelf_index` は0始まりです。

つまり、

```text
shelf_index 0:棚1
shelf_index 1:棚2
shelf_index 2:棚3
shelf_index 3:棚4
shelf_index 4:棚5
```

です。

---

# 18. 成功条件

フェーズ5Fの成功条件は、次の4つです。

```text
1. 棚選びボタンを押すと、棚番号が進む
2. LED1が棚番号ぶん点滅する
3. 選ばれた棚ファイルに応じて playlist.m3u が作り直される
4. mpvが新しい棚で再生し直される
```

棚番号の対応は次の通りです。

```text
1:声日記・生活
2:技術・AI・ものづくり
3:教育・研究・知の実践
4:本・文化・物語
5:TanaRadio
```

LED点滅の対応は次の通りです。

```text
1回点滅:声日記・生活
2回点滅:技術・AI・ものづくり
3回点滅:教育・研究・知の実践
4回点滅:本・文化・物語
5回点滅:TanaRadio
```

---

# 20. 今回はここで止めてよい

フェーズ5Fでは、ここまでで十分です。

今回できるようになることは、次です。

```text
物理ボタンで棚を選べる
LEDの点滅で棚番号を確認できる
棚ごとのRSSリストからプレイリストを作れる
選んだ棚をmpvで再生し直せる
```

これはかなり大きな進歩です。

フェーズ5Eまでは、棚はまだ「コマンドで指定するもの」でした。
フェーズ5Fで、棚は「手元のボタンで選ぶもの」になります。

ここから一気にラジオ感が出ます。

次のフェーズ5Gでは、5つ目のボタンとLED2を追加して、再生モードを選べるようにします。

候補は、まず次の3つで十分です。

```text
1:最新順
2:今日
3:3日前から古い順
```

「7日前から古い順」や「未聴っぽいもの」は、そのあとで大丈夫です。
ここで焦らないのが勝ち筋です。

#声日記 #TanaRadioPi

サマリー

TanaRadio Pi製作シリーズのフェーズ5Fでは、棚選びボタンとLED1の追加が行われました。まず、Raspberry Piの電源を切り、GPIO23に棚選びボタンを、GPIO24に棚番号を示すLEDを配線しました。その後、Raspberry Piを起動し、必要なソフトウェアの準備とPythonファイルの作成・実行を行いました。テストの結果、ボタンによる棚の切り替え、LEDによる棚番号の点滅表示、そして前回選択した棚の保存機能が期待通りに動作することを確認し、フェーズ5Fは成功裏に完了しました。次回フェーズ5Gでは、再生モード変更ボタンが追加される予定です。

導入と目標設定
それでは、TanaRadio Pi製作シリーズ、今日はフェーズ5Fになります。
目標は、棚選びボタンとLED1を追加するということで、いよいよですね、ハードウェアの工作を続けることになりました。
最初にボタン3つを付けてですね、その後はずっとソフトウェアの方をいじくる作業が続きましたが、やっと部品を追加するということで、今回は2つ部品を追加することになります。
まず1つ目は、棚を切り替えるためのボタン、押しボタンですね、これを付けます。
前回はですね、棚を変えるのにはソフトウェア上で、つまりターミナルでコマンドを打ち込むときに違う棚を選びましたが、その選択がボタンでできるということになります。
それからどの棚を選んでいるかということをですね、LEDランプが何回点灯するかということで示すことにしました。
これは1とか2とかいう数字を表示させたりですね、あるいはもっとさらに棚の名前を表示させたりするような技術は当然あるわけですけど、今回はですね、できるだけシンプルに実装するということで、LEDランプ1つでそれができるんじゃないかということでやってみたいと思っています。
ハードウェアの配線:ボタンとLED
まず第一段階ですけれども、まず部品を付けるので電源を切ります。
今、Raspberry Piに電源が入っている状態になっているんですけど、これをですね、シャットダウンしたいと思います。
はい、シャットダウンできました。
そうしましたらですね、今度は棚選びボタンを配線するということで、ボタンをですね、前の3つに並べまして、4つ目を付けたいと思います。
4つ目を今、ブレッドボードに差し込みまして、これにですね、抵抗を付けるんですね。1kΩの抵抗を付けるということで、抵抗の足の一方をですね、ラジオペンチで曲げてやりまして、抵抗を立てる形で差し込みます。
左右の足の長さが変わりますので、これをラジオペンチで長い方を切り落とします。
はい、大体同じくらいの長さにしました。
これをですね、一方はタクトスイッチ側、もう一方はGPIOの23番につなげるとなってますけど、そちらにつなぐ方に差したいと思います。
老眼になるとですね、もう小さいものが見えなくて、老眼用の眼鏡をかけていますが、これをさらに取らないと見えない感じですね。
はい、抵抗を今差しましたので、今度はその抵抗からGPIOの23番へとコード、ジャンパー線をですね、つなぐんですが、
短い方で足りると思ったらちょっと長さが足らないので、長い方の線を扱いたいと思います。
23番につなぎまして、タクトスイッチのもう片方の足はコードにつなぎました。
これで、棚選びボタンの配線はできたはずです。
では次に、棚番号LEDを配線します。
こちらはGPIOの24番につなぐということですね。
抵抗は330Ωの抵抗ということで、先ほどとは違うものをつなぎます。
こちらをLEDの長い足の方につなぐということで、LED5つ買ってあるんだけどね、赤いやつ。
長い足の方をGPIOの方につなぐということで、これを間違えると光らないそうです。
ではHを走ってみます。
ブレッドボードに挿したところです。
抵抗をGPIOとの間に入れるわけで、
新しく袋を開けまして、330Ωの抵抗を出しましたが、これは1kΩより相当小さいですね。
足の長さをニッパーで揃えました。
こちらをLEDの長い足に一方はつなぎ、
もう一方をGPIOの24に挿します。
これもジャンパー性は短かったので長いものと交換します。
24番に挿しまして、それでLEDの短い足の方をグラウンドに挿します。
グラウンドにつなぎました。
Raspberry Piの起動とソフトウェア準備
これで今回のボタンとLEDを追加するという配線は終わったと思いますので、
この状態でRaspberry Piの電源を入れます。
ケーブルをつないでいないときはケーブルをつなぐと自動的に入るのですが、
つないだ状態で電源を切りますと、電源ボタンというのが基板にありまして、そのボタンを押すことで電源が入ります。
これはRaspberry Piの5というバージョンからこの電源ボタンが付いたそうで、とても便利になりました。
では、起動しましたので、ターミナルを開いてソフトウェアの方を実装していくことになります。
今、ディスプレイの画面上で左側にターミナル、右側にブラウザーを開きまして、
右側のブラウザーでChatGPTの画面を開いて、マニュアルを出します。
出てきました。
そうしましたら、起動するというところまでやりましたので、次は作業用フォルダに移るわけですね。
作業用フォルダに移るコマンドをコピー&ペーストで入れまして実行。
pwdで確認です。はい、移りました。
そうしましたら、まず必要なパッケージを確認するということで、
今ですね、まずソフトウェアのアップデートをしました。
次に必要なパッケージもインストールするコマンドをコピー&ペーストでインストールされたみたいです。
そうしましたらですね、棚ファイルを確認するということなので、
lsコマンドを使いまして、前回作りました5つの棚のテキストファイル、これを確認しました。
それぞれ中身も確認すると言ってますが、
これは1つだけcatコマンドで中身を確認します。
ミスタイプをしましたので、もう一度入れます。
はい、昨日設定したものが入っていることを確認しました。
Pythonファイルの作成と実行権限設定
そうしましたらですね、次がメインですが、
5F用Pythonファイルを作るということで、またnanoというエディターを使いまして、新しいファイルを作ります。
これはもう、中身はですね、膨大な行のコードになってますので、
漏れなくコピーしてペーストしたいと思いますが、これがなかなか難しいですね。
最初と最後を間違えないように、今スクロール中ですが、相当の行数です。
何行あるか分かりませんが、まだ終わりません。
終わりました。
最後まで来ましたので、これをコピーしまして、エディターに貼り付けます。
これも確認などをすると、かえって良くないので、このままもう閉じてしまいます。
はい、閉じました。
そうしましたらですね、次は実行権限をつける。
これはいつもやっているものなんですけれども、また、今日はミスタイプが多いですね。
はい、できました。
で、実行ですね。
棚一覧とLEDの動作テスト
実行するわけですが、まず棚一覧を確認するということで、リストというオプションをつけますと、棚一覧が出るそうです。
はい、またエラーが出ましたね。
3行目に余計な文字を入れないでくださいと出ましたが、何か入っているんでしょうか。
余計な文字を入れないでくださいという、ChatGPTの注意書きまで含めてコピーしてしまったんですね。
これ消してやります。これで大丈夫だと思います。
もう一度ですね。今度はうまくいきまして、棚一覧が出ました。
はい、ここではですね、各棚のRSS数が書いてありますね。
はい、これも確認しました。
それでは、次はですね、LEDだけテストするということで、まずLEDテスト用のコマンドを入れます。
これは、オプションにテスト-LEDとを入れてやるようですが、
はい、今ですね、何が起こったかというと、声だけじゃなかなか分かりにくいんですが、
棚1、棚2、棚3と棚5まであるんですが、それぞれがですね、表示、ターミナルのところでその点滅テストというのが表示されつつ、
LEDが棚1だったら1回、棚2だったら2回点滅するというのを5までですね、つまりLEDが5回点滅するまで続けてやるという、そういう動作だったようです。
これ、今ちゃんと数えてなかったので正しいかどうか分かりませんから、もう1回やってみます。
はい、ターミナルを見ているとこのLEDの点滅の回数を数えられないので、今、ターミナルは見ずにLEDだけ見て点滅回数を数えていましたが、
確かに1回点滅から5回点滅まで順に点滅していました。LEDは大丈夫のようです。
メインプログラムの実行と機能確認
それではいよいよ、このフェーズ5Fで作ったPythonのプログラムを実行してみたいと思います。
そうしますとですね、これ音が出るんですよね。
ちょっと他の番組の音声ですので、一旦ここで収録を一時停止します。
はい、今テストをしてみましたが、この棚を進めるというボタン、1回押しますと棚が1つ進むんですけど、
1の棚から2の棚にという風に移るんですが、その時に2回LEDランプが点灯しまして、それで音声が流れるということを確認しました。
期待通りの動きをしているということを確認できたと思います。
また今までの再生、一時停止、戻る、次への3つのボタンですね。
これもちゃんとそれぞれの棚の中で前後したりするような動作をしていましたので、
これもOKだと思います。
棚の保存機能と成功条件の確認
ということでテストは終わりましたので、最後は前回の棚が保存されるかを確認するということなので、
一旦これを終了し、前回最後に選んだ棚が始まれば成功ということらしいです。
ちょっとこれも音が出るので一旦収録は一時停止します。
今テストをしましたけれども、棚の2で一旦終了させて、またこのプログラムを動かしましたら棚の2から始まりましたので、
前回最後に選んだ棚が保存されているということが確認できました。
これはjsonファイルに保存されているということなので、保存されているjsonファイルの中身をちょっと確認してみたいと思います。
中身もですね、shelf_02ということで、2番目の棚が保存されていることが分かりました。
ということで最後の成功条件ですね。
これ4つあるというので確認してみます。
1番目、棚選びボタンを押すと棚番号が進む。OKです。
2番目、LED1が棚番号を分点滅する。OKです。
3番目、選ばれた棚ファイルに応じてplaylist.m3uが作り直される。
ちょっとこれは確認はしてませんけど、そのように再生されていましたので、作り直されているんだと思います。
4番目、mpvが新しい棚で再生し直される。ということでこれも自動的に再生がされていました。
ということで、フェーズ5Fは成功ということで終わりました。
すんなりいきましたね。特にトラブルもなくうまくいったと思います。
次フェーズ5Gの展望
次はフェーズ5Gですけれども、またさらに5つ目のボタンを追加し、またもう1つLEDを追加するということで、
これ何をするボタンかと言いますと、再生モードを変更するためのボタンですね。
今は今から3日前の3日間ですね。3日前に遡って古い順に再生するという形にしていましたが、
このボタンをつけますと、今日公開があった配信、それから最新のものから順に古いものに遡ってなど、
いろいろな順序で聴くことが選べるようになるはずです。ということで、今日のところはこれで終わりにします。
それではまた。
26:21

コメント

スクロール