2026年2月25日

サイト収益をリアルタイムで表示する家具を作った

  

いつものようにYouTubeを漁っていると、何やら面白そうな機器を発見。
デジタルディスプレイ上に天気・株価・YouTubeのチャンネル登録者数などを表示できるらしい…

調べてみると、APIが叩けることが判明。

AdSenseの収益を常時表示してモチベーションを上げられないかと思い立ちました。

表示までの備忘録を残します。

システムアーキテクチャ

今回の構成はシンプルです。

        [ Cloud ]
+---------------------+
|   Google AdSense    |
+----------+----------+

        [ Local Network ]
           |
+----------v----------+
|   Raspberry Pi      |
+----------+----------+
           |
+----------v----------+
|      Pixoo 64       |
+---------------------+
  1. クラウド上の Google AdSense API から収益データを取得
  2. ローカルネットワーク内の Raspberry Pi (デバイスは何でも可)でPythonスクリプトを実行
  3. そのスクリプトが、Pixoo 64のローカルHTTP APIを叩く
  4. 最終的に収益をディスプレイへ表示

という流れです。

Pixoo APIの疎通確認

こちらのページにAPIにのリクエストがまとめてありました。
まずは疎通確認を行います。

まずは同じネットワークに接続したデバイスからPixooに直接リクエストを送ってみます。
<pixooのipアドレス>は自分のものに読み替えてください。

curl -v -X POST http://<pixooのipアドレス>/post \
-d '{"Command":"Device/GetDeviceTime"}'

error_code: 0 が返れば疎通成功です。

次にテキストが表示できるか確かめます。

curl -sS -X POST http://<pixooのipアドレス>/post -H "Content-Type: application/json" \
  -d '{"Command":"Draw/SendHttpText","TextId":1,"x":0,"y":40,"dir":0,"font":4,"TextWidth":64,"speed":0,"TextString":"HELLO PIXOO","color":"#FFFFFF","align":1}'

しかしここで問題がありました。
error_code 0 が返っているのに、画面が変わりません。

Pixooの描画の仕組み

Pixooは単に SendHttpText を送るだけでは画面に表示することができないようです。

そのため、まず SendHttpGif を送って、画面を真っ暗にします。

curl -sS -X POST http://<pixooのipアドレス>/post -H "Content-Type: application/json" \
  -d "{\"Command\":\"Draw/SendHttpGif\",\"PicNum\":1,\"PicID\":$PICID,\"PicOffset\":0,\"PicSpeed\":100,\"PicWidth\":64,\"PicData\":\"$PICDATA\"}"

次にテキストが表示できるか確かめます。

curl -sS -X POST http://<pixooのipアドレス>/post -H "Content-Type: application/json" \
  -d '{"Command":"Draw/SendHttpText","TextId":1,"x":0,"y":40,"dir":0,"font":4,"TextWidth":64,"speed":0,"TextString":"HELLO PIXOO","color":"#FFFFFF","align":1}'

この順番で送ることで、表示が切り変わることがわかりました。

以下コマンドを送ると標準の表示に戻ります。

curl -X POST http://<pixooのipアドレス>/post \
  -H "Content-Type: application/json" \
  -d '{"Command":"Channel/SetIndex","SelectIndex":0}'

Pythonから最小構成で表示してみる

curlで動作確認できたので、Pythonから画面リセット+テキスト表示のリクエストを叩きます。

import base64
import requests
from PIL import Image

PIXOO_IP = "<pixooのipアドレス>"  # 自分のpixooのipを指定する
PIXOO_URL = f"http://{PIXOO_IP}/post"


def post(payload: dict) -> dict:
    r = requests.post(PIXOO_URL, json=payload, timeout=5)
    r.raise_for_status()
    return r.json()


def make_black_frame_picdata_64x64() -> str:
    """64x64黒画像を作って、PixooのPicData用にbase64エンコードして返す"""
    img = Image.new("RGB", (64, 64), (0, 0, 0))
    raw = img.tobytes()  # RGB 64*64*3 bytes
    return base64.b64encode(raw).decode("ascii")


def main():
    # 背景(黒フレーム)を生成
    picdata = make_black_frame_picdata_64x64()

    # PicID取得
    picid_resp = post({"Command": "Draw/GetHttpGifId"})
    pic_id = picid_resp.get("PicID") or picid_resp.get("PicId") or 1
    print("Draw/GetHttpGifId:", picid_resp, "=> PicID:", pic_id)

    # 背景送信
    gif_resp = post({
        "Command": "Draw/SendHttpGif",
        "PicNum": 1,
        "PicID": int(pic_id),
        "PicOffset": 0,
        "PicSpeed": 100,
        "PicWidth": 64,
        "PicData": picdata,
    })
    print("Draw/SendHttpGif:", gif_resp)

    text_resp = post({
        "Command": "Draw/SendHttpText",
        "TextId": 1,
        "x": 0,
        "y": 40,
        "dir": 0,
        "font": 4,
        "TextWidth": 64,
        "speed": 0,
        "TextString": "HELLO PIXOO",
        "color": "#FFFFFF",
        "align": 1
    })
    print("Draw/SendHttpText:", text_resp)


if __name__ == "__main__":
    main()
「HELLO PIXOO」と表示されることが確認できた。

これでPython経由で表示できることを確認しました。

Adsense APIから収益を取得する

AdSense APIの認証および環境構築は、以下の記事を参考にしました。

https://qiita.com/jianyi/items/9a1c02851de4a8a8f1c2

今回取得しているデータは以下の3つです。

TODAY →今日の収益
LAST_7_DAYS →1週間の収益
LAST_30_DAYS→1ヶ月の収益

これらをそのまま画面に表示してみます。

import os
import time
import base64
from pathlib import Path
from typing import Optional, Any, Dict

import requests
from PIL import Image

from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

# ====== 設定 ======
PIXOO_IP = "<pixooのipアドレス>"  # ここを書き換える
PIXOO_URL = f"http://{PIXOO_IP}/post"

SCOPES = ["https://www.googleapis.com/auth/adsense.readonly"]

# AdSense dateRange
RANGE_TODAY = "TODAY"
RANGE_WEEK = "LAST_7_DAYS"
RANGE_MONTH = "LAST_30_DAYS"
# ==================

HERE = Path(__file__).resolve().parent
CLIENT_SECRET = HERE / "client_secret.json"  # 手元のファイル名に合わせてください
TOKEN_FILE = HERE / "token.json"


def post(payload: Dict[str, Any]) -> Dict[str, Any]:
    r = requests.post(PIXOO_URL, json=payload, timeout=5)
    r.raise_for_status()
    return r.json()


def pixoo_get_http_gif_id() -> int:
    data = post({"Command": "Draw/GetHttpGifId"})
    pic_id = data.get("PicID") or data.get("PicId") or data.get("picId") or 1
    return int(pic_id)


def make_black_frame_picdata_64x64() -> str:
    img = Image.new("RGB", (64, 64), (0, 0, 0))
    raw = img.tobytes()
    return base64.b64encode(raw).decode("ascii")


def pixoo_send_black_bg():
    picdata = make_black_frame_picdata_64x64()
    pic_id = pixoo_get_http_gif_id()
    resp = post({
        "Command": "Draw/SendHttpGif",
        "PicNum": 1,
        "PicID": pic_id,
        "PicOffset": 0,
        "PicSpeed": 100,
        "PicWidth": 64,
        "PicData": picdata,
    })
    print("Draw/SendHttpGif:", resp)


def pixoo_text(text_id: int, x: int, y: int, s: str):
    resp = post({
        "Command": "Draw/SendHttpText",
        "TextId": text_id,
        "x": x,
        "y": y,
        "dir": 0,
        "font": 4,
        "TextWidth": 64,
        "speed": 0,
        "TextString": s,
        "color": "#FFFFFF",
        "align": 1,
    })
    print("Draw/SendHttpText:", resp)


def get_adsense_service() -> Any:
    creds: Optional[Credentials] = None
    if TOKEN_FILE.exists():
        creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)

    if not creds or not creds.valid:
        flow = InstalledAppFlow.from_client_secrets_file(str(CLIENT_SECRET), SCOPES)
        # 初回はローカルで認証(GUIなし環境なら run_console() に変える)
        creds = flow.run_local_server(port=0)
        TOKEN_FILE.write_text(creds.to_json(), encoding="utf-8")

    return build("adsense", "v2", credentials=creds)


def pick_account_name(service: Any) -> str:
    resp = service.accounts().list(pageSize=50).execute()
    accounts = resp.get("accounts", [])
    if not accounts:
        raise RuntimeError("No AdSense accounts found.")
    return accounts[0]["name"]


def get_total(service: Any, account_name: str, date_range: str) -> float:
    report = service.accounts().reports().generate(
        account=account_name,
        dateRange=date_range,
        metrics=["ESTIMATED_EARNINGS"],
    ).execute()

    totals = report.get("totals")
    if not totals:
        return 0.0

    # totals は dict の場合がある
    if isinstance(totals, dict):
        return float(totals["cells"][0]["value"])
    if isinstance(totals, list):
        return float(totals[0]["cells"][0]["value"])

    return 0.0


def main():
    # 1) AdSenseから取得
    service = get_adsense_service()
    account = pick_account_name(service)

    today = get_total(service, account, RANGE_TODAY)
    week = get_total(service, account, RANGE_WEEK)
    month = get_total(service, account, RANGE_MONTH)

    print("Fetched:", {"today": today, "week": week, "month": month})

    # 2) Pixooへ表示(UI調整前のベタ表示)
    pixoo_send_black_bg()

    # SendHttpGif直後に少し待つ(表示が一瞬で消える対策)
    time.sleep(0.2)

    # 円記号は□になることがあるので、まずは数値だけ表示(必要なら "JPY" を別で)
    pixoo_text(1, 0, 6,  f"TODAY: {int(round(today))}")
    pixoo_text(2, 0, 22, f"7D:    {int(round(week))}")
    pixoo_text(3, 0, 38, f"30D:   {int(round(month))}")


if __name__ == "__main__":
    main()
こんな感じで表示できた

画面を暗くした後に少し待たないと、テキスト表示がうまくいかないことが頻発したため、
SendHttpGif の直後に少し待ち時間を入れることで安定して表示させるようにしました。

UIをドット絵風にいじる

せっかくドットなので、UIをいい感じに調整します。

上からその日の収益が5円、7日間で61円、30日間で221円だったことを示している。画面は30分ごとに更新するようにしている。

以下の流れで画面を表示させるようにしました。

  1. AdSense APIから収益を取得(today / 7days / 30days)
  2. Pixooに背景(GIF=64×64の画像)を送る
    • ここで「収益以外の情報(タイトル・枠・アイコン等)」が描かれた背景を描画する
  3. 短い待ち(例:time.sleep(0.2))
    • 背景描画がPixoo側で完全に反映されるのを待つ
  4. Pixooにテキストを送る(収益の数値を SendHttpText で複数行)
import base64
import time
import traceback
from pathlib import Path
from typing import Optional, Any, Dict

import requests
from PIL import Image, ImageDraw, ImageFont

from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

DEBUG = True

SCOPES = ["https://www.googleapis.com/auth/adsense.readonly"]

# ===== 設定 =====
PIXOO_IP = "<pixooのipアドレス>"  # ここを書き換える
UPDATE_SECONDS = 30 * 60

RANGE_TODAY = "TODAY"
RANGE_WEEK = "LAST_7_DAYS"
RANGE_MONTH = "LAST_30_DAYS"
# =================

HERE = Path(__file__).resolve().parent
CLIENT_SECRET = HERE / "client_secret.json" # 手元のファイル名に合わせてください
TOKEN_FILE = HERE / "token.json"

PIXOO_URL = f"http://{PIXOO_IP}/post"

FONT_FILE = HERE / "PressStart2P-Regular.ttf"


# ---------------- AdSense ----------------
def get_adsense_service():
    creds: Optional[Credentials] = None
    if TOKEN_FILE.exists():
        creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)

    if not creds or not creds.valid:
        flow = InstalledAppFlow.from_client_secrets_file(str(CLIENT_SECRET), SCOPES)
        creds = flow.run_local_server(port=0)
        TOKEN_FILE.write_text(creds.to_json(), encoding="utf-8")

    return build("adsense", "v2", credentials=creds)


def pick_account_name(service):
    resp = service.accounts().list(pageSize=50).execute()
    accounts = resp.get("accounts", [])
    if not accounts:
        raise RuntimeError("No AdSense accounts found.")
    return accounts[0]["name"]


def get_total(service, account_name, date_range) -> float:
    report = service.accounts().reports().generate(
        account=account_name,
        dateRange=date_range,
        metrics=["ESTIMATED_EARNINGS"],
    ).execute()

    totals = report.get("totals")
    if not totals:
        return 0.0

    if isinstance(totals, dict):
        return float(totals["cells"][0]["value"])

    if isinstance(totals, list):
        return float(totals[0]["cells"][0]["value"])

    return 0.0


# ---------------- Pixoo ----------------
def pixoo_post(payload: Dict[str, Any]) -> Dict[str, Any]:
    r = requests.post(PIXOO_URL, json=payload, timeout=5)
    r.raise_for_status()
    return r.json()


def pixoo_get_http_gif_id() -> int:
    data = pixoo_post({"Command": "Draw/GetHttpGifId"})
    pic_id = data.get("PicID") or data.get("PicId") or data.get("picId")
    try:
        return int(pic_id)
    except Exception:
        return 1


def pixoo_send_http_gif(picdata_b64: str):
    pic_id = pixoo_get_http_gif_id()
    payload: Dict[str, Any] = {
        "Command": "Draw/SendHttpGif",
        "PicNum": 1,
        "PicID": pic_id,
        "PicOffset": 0,
        "PicSpeed": 100,
        "PicWidth": 64,
        "PicData": picdata_b64,
    }
    pixoo_post(payload)


def pixoo_text(text_id: int, x: int, y: int, text: str):
    payload = {
        "Command": "Draw/SendHttpText",
        "TextId": text_id,
        "x": x,
        "y": y,
        "dir": 0,
        "font": 4,
        "TextWidth": 64,
        "speed": 0,
        "TextString": text,
        "color": "#FFFFFF",
        "align": 1,
    }
    pixoo_post(payload)


def format_money(v: float) -> str:
    return f"{int(round(v))}"


# ---------------- Pixel Background(アイコン復活版) ----------------
def _load_font(size: int):
    try:
        if FONT_FILE.exists():
            return ImageFont.truetype(str(FONT_FILE), size)
    except Exception:
        pass
    return ImageFont.load_default()


def _draw_bg(dr: ImageDraw.ImageDraw):
    for y in range(64):
        for x in range(64):
            base = 10
            if ((x // 4) + (y // 4)) % 2 == 0:
                base = 12
            dr.point((x, y), fill=(base, base, base))
    dr.rectangle([0, 0, 63, 63], outline=(70, 70, 70))
    dr.rectangle([2, 2, 61, 61], outline=(30, 30, 30))


def _draw_icon_clock(dr: ImageDraw.ImageDraw, x: int, y: int):
    outline = (160, 220, 255)
    fill = (30, 60, 70)
    dr.rectangle([x, y, x + 7, y + 7], outline=outline, fill=fill)
    dr.point((x + 4, y + 4), fill=outline)
    dr.point((x + 4, y + 3), fill=outline)
    dr.point((x + 5, y + 4), fill=outline)


def _draw_icon_calendar(dr: ImageDraw.ImageDraw, x: int, y: int):
    outline = (220, 220, 220)
    fill = (70, 70, 70)
    top = (120, 160, 255)
    dr.rectangle([x, y + 1, x + 7, y + 7], outline=outline, fill=fill)
    dr.rectangle([x, y + 1, x + 7, y + 2], fill=top)


def _draw_icon_coin(dr: ImageDraw.ImageDraw, x: int, y: int):
    """8x8 round coin sprite (fills the existing 8x8 slot; no coordinate changes)."""
    edge = (120, 90, 0)         # dark gold edge
    face = (255, 210, 0)        # gold face
    hi = (255, 240, 150)        # highlight

    # 8x8 pattern (top-left at x, y):
    # ..####..
    # .######.
    # ##****##
    # ##*+**##
    # ##****##
    # ##****##
    # .######.
    # ..####..
    pattern = [
        "..####..",
        ".######.",
        "##****##",
        "##*+**##",
        "##****##",
        "##****##",
        ".######.",
        "..####..",
    ]

    for py, row in enumerate(pattern):
        for px, ch in enumerate(row):
            if ch == ".":
                continue
            if ch == "#":
                color = edge
            elif ch == "*":
                color = face
            else:  # '+'
                color = hi
            dr.point((x + px, y + py), fill=color)


def render_background_picdata() -> str:
    im = Image.new("RGB", (64, 64), (0, 0, 0))
    dr = ImageDraw.Draw(im)

    _draw_bg(dr)

    f_title = _load_font(8)
    f_row = _load_font(7)

    blue = (120, 180, 255)

    dr.text((6, 2), "SITE", font=f_title, fill=blue)
    dr.text((6, 11), "REVENUE", font=f_title, fill=blue)

    dr.line([6, 22, 57, 22], fill=(60, 60, 60))

    # アイコン + ラベル(TD→1Dのみ変更)
    _draw_icon_clock(dr, 6, 26)
    _draw_icon_calendar(dr, 6, 38)
    _draw_icon_coin(dr, 6, 51)

    dr.text((16, 24), "1D", font=f_row, fill=(220, 220, 220))
    dr.text((16, 37), "7D", font=f_row, fill=(220, 220, 220))
    dr.text((16, 49), "30D", font=f_row, fill=(220, 220, 220))

    return base64.b64encode(im.tobytes()).decode("ascii")


BACKGROUND_PICDATA = render_background_picdata()


# ---------------- Main Loop ----------------
def main():
    service = get_adsense_service()
    account = pick_account_name(service)

    while True:
        try:
            today = get_total(service, account, RANGE_TODAY)
            week = get_total(service, account, RANGE_WEEK)
            month = get_total(service, account, RANGE_MONTH)

            pixoo_send_http_gif(BACKGROUND_PICDATA)
            time.sleep(0.2)  # 少し待ってからテキストを送らないと、背景が更新される前にテキストが送られてしまうことがある

            x_val = 38            

            pixoo_text(11, x_val, 22, format_money(today))

            pixoo_text(12, x_val, 34, format_money(week))

            pixoo_text(13, x_val, 46, format_money(month))

        except Exception as e:
            print("Update failed:", repr(e))
            traceback.print_exc()

        time.sleep(UPDATE_SECONDS)


if __name__ == "__main__":
    main()

Raspberry Piで常時起動できるようにする

テストはMacから行なっていましたが、
プログラムを常時実行させるため、家にあったRaspberry Pi 3Bにコードを移行しました。

systemdで常時起動させるようにしています。
この記事ではやり方については範囲外とします。

まとめ

Pixoo64を使ってGoogle Adsenseから取得した情報を表示させてみました。

コード全体はGitHubに公開しています。

https://github.com/kooooooooooh/pixoo-adsense-display

API連携できればなんでも表示できるので、他にもいろんなものに使えそうです。