A Connect IQ application that receives and displays BLE advertisements from Japan's Advanced PICS (Pedestrian Information and Communication System) pedestrian traffic signals in real-time — no smartphone required.
- Real-time reception of PICS BLE advertisements (company ID
0x01CE) - Displays signal state (Red / Green / Blinking Green / Uncontrolled) for up to 16 pedestrian channels with dynamic scrolling and empty signal filtering
- Shows receive status, last receive time, packet count, and current GPS position in a compact header
- Shows the Type 1 transmitter/device ID (for example
SAPPORO_STN001) under the resolved intersection name when available - Resolves intersection name from GPS coordinates using a pre-built national intersection database (596 intersections)
- Displays device GPS coordinates, nearest intersection coordinates, and calculated distance + bearing
- Keeps nearby intersection names and addresses clear of the right-aligned distance and bearing using pixel-aware truncation, including for wide Japanese glyphs
- Works standalone on the GPSMAP H1i Plus without any companion app
- SD Card logging: Record raw BLE payloads and packet data directly to the device's internal storage
Advanced-PICS-Traffic-Signals-for-GARMIN/
├── manifest.xml ← App definition, permissions (BLE + Positioning)
├── monkey.jungle ← Project build configuration
├── source/
│ ├── ApiFlags.mc ← Auto-generated compile-time feature flags
│ ├── PicsBleApp.mc ← App entry point, lifecycle, menu handling
│ ├── PicsBleDelegate.mc ← BLE scanning & packet reception
│ ├── PicsData.mc ← Data models, PICS parser, intersection DB
│ └── PicsMainView.mc ← Screen rendering (282×470 px)
├── resources/
│ ├── data/
│ │ ├── intersections.json ← Intersection database (auto-generated)
│ │ └── jsonResources.xml ← JSON resource declaration
│ ├── drawables/
│ │ ├── drawables.xml ← Drawable resource declarations
│ │ └── launcher_icon.png ← App launcher icon
│ └── strings/
│ └── strings.xml ← UI strings (Japanese; add translations here)
├── tools/
│ ├── build.ps1 ← Windows build helper
│ ├── clean_intersections.py ← Address cleanup helper
│ ├── csv_to_resource.py ← CSV → JSON resource converter
│ ├── debug_project.py ← Project diagnostics & API flag generator
│ ├── ble_pics_advertiser.py ← macOS BLE test advertiser
│ ├── make_ble_advertiser_app.sh ← Builds the macOS app bundle for the advertiser
│ └── BlePicsAdvertiser.cs ← Windows BLE test advertiser source
└── tests/
└── test_csv_to_resource.py ← CSV converter regression tests
Target Devices
- GARMIN GPSMAP H1i Plus (primary)
- GARMIN GPSMAP H1 (inReach-free variant)
Minimum Connect IQ API Level: 5.0.0
Toybox.BluetoothLowEnergy(BLE scanning): API Level 3.1.0+ScanResult.getManufacturerSpecificData(companyId): API Level 3.2.0+Toybox.Position(GPS): API Level 1.0.0+
Required Permissions
| Permission | Purpose |
|---|---|
BluetoothLowEnergy |
Receive PICS BLE advertisements |
Positioning |
Read device GPS coordinates for intersection lookup |
BLE Scanning
| Item | Details |
|---|---|
| Mode | Central (scan only) |
| Filter | Manufacturer ID = 0x01CE (PICS-specific) |
| Pairing | Not required (advertisement reception only) |
| GATT connection | Not established |
| API | ScanResult.getManufacturerSpecificData(companyId) returns ByteArray directly |
PICS Packet Format (bytes after company ID 0x01CE):
| Bytes | Content |
|---|---|
| [0..1] | Header (version / flags) |
| [2] | Message type (0 = identifier, 1 = location, 2 = signal state) |
| [3] | Message ID |
| [4..5] | Reserved |
| [6..9] | Intersection ID (4-byte hex) |
| [10+i] | Signal state (Type 2 only): upper 4 bits = remaining time, lower 4 bits = state |
Download from https://developer.garmin.com/connect-iq/sdk/
openssl genrsa -out developer_key.pem 4096
openssl pkcs8 -topk8 -inform PEM -outform DER \
-in developer_key.pem -out developer_key.der -nocryptpython3 tools/debug_project.py --generate-api-flagsThis auto-generates source/ApiFlags.mc with compile-time constants based on the installed SDK.
# Default: reads ~/Downloads/250501-zenkoku-kousaten.csv
python3 tools/csv_to_resource.py
# Or specify paths explicitly:
python3 tools/csv_to_resource.py /path/to/input.csv resources/data/intersections.jsonmonkeyc -d gpsmaph1 -f monkey.jungle -o bin/pics-viewer.prg -y developer_key.derOn Windows, you can also use the repository build helper. It looks for
monkeyc on PATH and in the default Garmin SDK Manager install locations:
.\tools\build.ps1
# Or specify an SDK path explicitly:
.\tools\build.ps1 -SdkPath "C:\Path\To\connectiq-sdk-win-9.2.0"To build for a different device, replace gpsmaph1 with the target device ID.
Run the repository tests with:
python3 -m unittest discover -s tests -vEnsure USB Mass Storage Mode
cp bin/pics-viewer.prg /GARMIN/APPS/The app automatically logs every received PICS packet with a wall-clock timestamp, monotonic receive tick, and raw HEX payload. However, Garmin OS discards these logs unless you manually create a text file to capture them:
- Connect your Garmin device via USB.
- In the
GARMIN/APPS/LOGS/directory, create an empty text file named exactly the same as your app executable but with a.txtextension (e.g.pics-viewer.txt). - Launch the app. All BLE traffic logs will be continuously appended to that text file.
tools/ble_pics_advertiser.py broadcasts a sample PICS packet (company ID 0x01CE, Type 2, intersection ID DEADBEEF) for 15 seconds, so the Connect IQ app can be exercised without real traffic-signal hardware. The Windows equivalent is tools/BlePicsAdvertiser.cs.
python3 -m venv .venv
.venv/bin/python -m pip install pyobjc-framework-CoreBluetoothCreate the virtual environment with the Python installation you intend to keep. A venv whose base interpreter is later removed (for example after switching from Intel to Apple Silicon Homebrew) leaves broken shebangs; pip3 then falls through to the system Python and fails with externally-managed-environment. Recreate the venv in that case.
macOS refuses CoreBluetooth access unless the responsible application declares NSBluetoothAlwaysUsageDescription in its Info.plist. When a script is started from a shell, the responsible application is the terminal emulator — not Python and not the script. Terminal.app and Warp do not declare that key, so the interpreter is killed by TCC before it can advertise:
This app has crashed because it attempted to access privacy-sensitive data
without a usage description. The app's Info.plist must contain an
NSBluetoothAlwaysUsageDescription key ...
The process aborts with exit code 134 and writes a crash report to ~/Library/Logs/DiagnosticReports/. This is platform policy, not a bug in the script: no Python-side change can bypass it, including patching NSBundle.mainBundle() at runtime.
Build a small app bundle that declares the usage description and embeds the framework interpreter:
tools/make_ble_advertiser_app.sh # build only
tools/make_ble_advertiser_app.sh --run # build, launch, and print the logLaunch it again at any time with:
open -a build/PICSBLEAdvertiser.appLaunching through open makes the bundle its own responsible application, so its Info.plist satisfies the check. macOS shows the Bluetooth permission prompt on first launch, and the grant appears under System Settings → Privacy & Security → Bluetooth as PICS BLE Advertiser. The bundle runs without a terminal, so output goes to build/ble-advertiser.log:
Publisher status: Started
Broadcasting CompanyId=0x01CE ID=DEADBEEF Type=2 for 15 seconds
Final status: Stopped
The bundle is a local build artifact (ignored by git). Rebuild it after upgrading Python or recreating the virtual environment.
Running the script directly works only from a terminal application that declares the key and has been granted Bluetooth access:
.venv/bin/python tools/ble_pics_advertiser.pyCheck a terminal before relying on it:
/usr/libexec/PlistBuddy -c "Print :NSBluetoothAlwaysUsageDescription" \
"/Applications/Visual Studio Code.app/Contents/Info.plist"Visual Studio Code declares the key, so its integrated terminal works once Bluetooth access is granted to VS Code. Terminal.app and Warp do not declare it and cannot run the advertiser directly — use Option 1 there. Editing Homebrew's own Python.app/Contents/Info.plist also works, but is not recommended: it affects every Python process, invalidates the framework signature, and is reverted by each brew upgrade.
The delegate class must call objc.super(...), not the built-in super(). PyObjC subclasses of NSObject raise AttributeError: 'super' object has no attribute 'init' with the plain built-in.
- BLE: Central (scan-only) mode. No GATT connection is established.
- macOS test advertiser: Requires an application bundle that declares
NSBluetoothAlwaysUsageDescription; see BLE Test Advertiser (macOS). - Widget type: The app runs as a Connect IQ widget, accessible via the PAGE button on the device. BLE scanning runs continuously while the widget is active.
- Widget vs watch-app: As a
widget, the app is launched directly from the widget glance view (PAGE button) rather than navigating to Main Menu → Connect IQ → Apps. - UI verification mode: Press MENU to toggle a mock-data display mode for checking rendering on hardware when BLE/simulator behavior is unreliable.
- Screen layout: The footer is removed; receive metadata is shown in the compact header and cards use the remaining screen height.
- Nearby rows: Intersection names and addresses are truncated by rendered pixel width, leaving a fixed gap before distance and bearing values instead of wrapping or overlapping them.
- Intersection database: Built from the e-Gov national intersection list (596 intersections). Re-run
csv_to_resource.pyto update. - Identification of traffic signs: Need to dump network telemetry of NIPPON SIGNAL's App. ref.
- String resources: Status and empty-state labels are defined in
resources/strings/strings.xml. To add a language translation, create a locale-specific strings file (e.g.resources-eng/strings/strings.xml). - Connect IQ SDK:
ScanResult.getManufacturerSpecificData()requires the company ID as an argument and returns aByteArraydirectly (no longer returns aDictionary). - Simulator: The Connect IQ SDK 9.1.0 simulator may crash on macOS 26+ (Tahoe) in the
ant_mainthread when loading apps that use BLE. Deploy to actual hardware for testing until Garmin releases a compatible SDK update. - Legal compliance: PICS BLE advertisements are public-infrastructure transmissions broadcast to all. This app is receive-only and does not transmit or modify any signal. Reception is compliant with Japanese radio and telecommunications law.
This project is deeply inspired by and based on the work of yumu19/ble-pics-viewer (Python version). We express our sincere gratitude to the original author for making their packet analysis and implementation public, which made this Garmin port possible.
- yumu19/ble-pics-viewer (Python version)
- Garmin Connect IQ API — BluetoothLowEnergy
- Advanced PICS intersection list (e-Gov)
- 信号の時刻表をつくる 2025 (デイリーポータルZ)
- 信GO!アプリの解析 (私の自由研究 2025) #Android - Qiita
Connect IQ アプリとして実装した、歩行者用信号機 BLE アドバタイズのリアルタイム受信・表示ツールです。スマートフォン不要・単体で高度化PICS信号機のBLEアドバタイズを受信し、交差点の信号状態(赤・青・青点滅)をリアルタイム表示します。
ble-pics-viewer(Python版)の機能を GARMIN GPSMAP H1i Plus 上で動作する Connect IQ アプリとして移植したものです。
Advanced-PICS-Traffic-Signals-for-GARMIN/
├── manifest.xml ← アプリ定義・権限宣言(BLE + Positioning)
├── monkey.jungle ← プロジェクトビルド設定
├── source/
│ ├── ApiFlags.mc ← 自動生成されるコンパイル時フラグ
│ ├── PicsBleApp.mc ← エントリポイント・ライフサイクル管理
│ ├── PicsBleDelegate.mc ← BLEスキャン・パケット受信
│ ├── PicsData.mc ← データモデル・PICSパーサ・交差点DB
│ └── PicsMainView.mc ← 画面描画(282×470px)
├── resources/
│ ├── data/
│ │ ├── intersections.json ← 交差点データベース(自動生成)
│ │ └── jsonResources.xml ← JSONリソース宣言
│ ├── drawables/
│ │ ├── drawables.xml ← 画像リソース宣言
│ │ └── launcher_icon.png ← アプリアイコン
│ └── strings/
│ └── strings.xml ← UIラベル文字列(日本語)
├── tools/
│ ├── build.ps1 ← Windows向けビルド補助
│ ├── clean_intersections.py ← 住所データの整形補助
│ ├── csv_to_resource.py ← CSV → JSON リソース変換スクリプト
│ ├── debug_project.py ← プロジェクト診断・APIフラグ生成
│ ├── ble_pics_advertiser.py ← macOS BLEテスト送信ツール
│ ├── make_ble_advertiser_app.sh ← 上記を実行する macOS アプリバンドルの生成
│ └── BlePicsAdvertiser.cs ← Windows BLEテスト送信ツールのソース
└── tests/
└── test_csv_to_resource.py ← CSV変換ツールの回帰テスト
- GARMIN GPSMAP H1i Plus(メインターゲット)
- GARMIN GPSMAP H1(inReach なし版も対応)
- API Level 5.0.0 以上
Toybox.BluetoothLowEnergy(BLEスキャン機能)API Level 3.1.0〜ScanResult.getManufacturerSpecificData(companyId)API Level 3.2.0〜Toybox.Position(GPS)API Level 1.0.0〜
| パーミッション | 用途 |
|---|---|
BluetoothLowEnergy |
PICSのBLEアドバタイズ受信 |
Positioning |
GPS座標取得(交差点名の解決に使用) |
| 項目 | 内容 |
|---|---|
| 動作モード | セントラル(スキャン専用) |
| フィルタ条件 | メーカーID = 0x01CE(高度化PICS固有) |
| ペアリング | 不要(アドバタイズ受信のみ) |
| GATT接続 | 行わない |
| API | getManufacturerSpecificData(companyId) で ByteArray を直接取得(SDK 8.x) |
BLEアドバタイズのメーカー固有データ(company ID 0x01CE)の後続バイト列を解析:
| バイト | 内容 |
|---|---|
| [0..1] | ヘッダー(バージョン・フラグ) |
| [2] | メッセージタイプ(0/1/2) |
| [3] | メッセージID |
| [4..5] | 予約 |
| [6..9] | 交差点ID(4バイト hex) |
| [10+i] | 信号状態(タイプ2のみ)上位4bit=残り時間, 下位4bit=状態 |
- Garmin Connect IQ SDK をインストール
- Visual Studio Code + Monkey C 拡張機能 を導入
openssl genrsa -out developer_key.pem 4096
openssl pkcs8 -topk8 -inform PEM -outform DER -in developer_key.pem -out developer_key.der -nocryptpython3 tools/debug_project.py --generate-api-flagsこれにより、インストールされているSDKに基づいてコンパイル時定数を持つ source/ApiFlags.mc が生成されます。
# デフォルト: ~/Downloads/250501-zenkoku-kousaten.csv を読み込む
python3 tools/csv_to_resource.py
# パスを明示する場合:
python3 tools/csv_to_resource.py /path/to/input.csv resources/data/intersections.jsonmonkeyc -d gpsmaph1 -f monkey.jungle -o bin/pics-viewer.prg -y developer_key.derUSBマスストレージモードにすること
cp bin/pics-viewer.prg /GARMIN/APPS/python3 -m unittest discover -s tests -v受信したPICSパケットの生データ(HEXダンプ)、信号状態、電波強度が、実時刻と単調増加する受信Tickとともに記録されます。GarminのOS仕様上、以下の手順を実施しないとログはデバイスに保存されません。
- GarminデバイスをPCにUSB接続します。
- デバイス内の
/GARMIN/APPS/LOGS/フォルダを開きます。 - アプリの実行ファイル名と同じ名前を持つ、空のテキストファイル(例:
pics-viewer.txt)を作成します。 - アプリを起動すると、このテキストファイルに受信ログが追記され続けます(長時間の連続記録によるファイル容量肥大化にご注意ください)。
tools/ble_pics_advertiser.py は、company ID 0x01CE・Type 2・交差点ID DEADBEEF のサンプルPICSパケットを15秒間送信します。実際の信号機がなくても Connect IQ アプリの受信動作を確認できます(Windows版は tools/BlePicsAdvertiser.cs)。
python3 -m venv .venv
.venv/bin/python -m pip install pyobjc-framework-CoreBluetooth仮想環境は、今後も使い続ける Python で作成してください。ベースとなる Python が後から削除されると(例: Homebrew を Intel 版から Apple Silicon 版へ移行した場合)venv の shebang が壊れ、pip3 がシステム Python にフォールバックして externally-managed-environment エラーになります。その場合は venv を作り直してください。
macOS では、責任アプリケーション (responsible application) の Info.plist に NSBluetoothAlwaysUsageDescription が宣言されていない限り、CoreBluetooth の利用が拒否されます。シェルからスクリプトを起動した場合、責任アプリケーションは Python でもスクリプトでもなくターミナルアプリです。Terminal.app や Warp はこのキーを宣言していないため、送信を開始する前に TCC によってプロセスが強制終了されます。
This app has crashed because it attempted to access privacy-sensitive data
without a usage description. The app's Info.plist must contain an
NSBluetoothAlwaysUsageDescription key ...
プロセスは終了コード 134 で異常終了し、クラッシュレポートが ~/Library/Logs/DiagnosticReports/ に出力されます。これはスクリプトの不具合ではなく OS のポリシーです。実行時に NSBundle.mainBundle() を書き換える等の Python 側の回避策では解決できません。
使用目的の説明を宣言し、フレームワーク版インタプリタを内包した小さなアプリバンドルを生成します。
tools/make_ble_advertiser_app.sh # 生成のみ
tools/make_ble_advertiser_app.sh --run # 生成・起動してログを表示2回目以降は次のコマンドで起動できます。
open -a build/PICSBLEAdvertiser.appopen で起動するとバンドル自身が責任アプリケーションになるため、そのバンドルの Info.plist で権限チェックを満たせます。初回起動時に Bluetooth の許可ダイアログが表示され、システム設定 → プライバシーとセキュリティ → Bluetooth に PICS BLE Advertiser として登録されます。ターミナルを介さずに動作するため、出力は build/ble-advertiser.log に記録されます。
Publisher status: Started
Broadcasting CompanyId=0x01CE ID=DEADBEEF Type=2 for 15 seconds
Final status: Stopped
このバンドルはローカルのビルド成果物です(git 管理外)。Python の更新や venv の作り直し後は再生成してください。
スクリプトを直接実行できるのは、当該キーを宣言し、かつ Bluetooth へのアクセスを許可されたターミナルアプリのみです。
.venv/bin/python tools/ble_pics_advertiser.py事前確認:
/usr/libexec/PlistBuddy -c "Print :NSBluetoothAlwaysUsageDescription" \
"/Applications/Visual Studio Code.app/Contents/Info.plist"Visual Studio Code はこのキーを宣言しているため、VS Code に Bluetooth 権限を許可すれば統合ターミナルから実行できます。Terminal.app と Warp は宣言していないため直接実行できません(方法1を使用してください)。Homebrew の Python.app/Contents/Info.plist を編集する方法も動作しますが、すべての Python プロセスに影響し、フレームワークの署名が無効化され、brew upgrade のたびに元へ戻るため推奨しません。
デリゲートクラスでは組み込みの super() ではなく objc.super(...) を使用する必要があります。PyObjC の NSObject サブクラスで組み込みの super() を使うと AttributeError: 'super' object has no attribute 'init' が発生します。
- GATTクライアントのみ:アドバタイズの送信・GATTサーバーは不可
- ウィジェット動作: 本アプリはWidgetとして動作し、PAGEボタンでアクセスします。
- スキャンはフォアグラウンド動作時のみ安定:バックグラウンドではOSが間引く場合あり
- ScanResult のフィルタリングはアプリ側で実装(Connect IQ はUUIDフィルタ非対応)
- SDK 8.x/9.x 対応:
ScanResult.getManufacturerSpecificData()はcompany IDを引数で指定しByteArrayを直接返す(旧APIはDictionaryを返していたが廃止) - エミュレータ確認モード:BLEやシミュレーターが不安定な場合でも、MENUボタンでモックデータ表示に切り替えてUI描画を確認できます。
- 画面レイアウト:フッターは削除し、受信情報はコンパクトなヘッダーへ集約しています。
- 周辺信号の表示:交差点名と住所は、文字数ではなく実際の描画幅に合わせて省略されます。日本語の全角文字を含む場合も、右寄せの距離・方角と重なったり折り返したりしません。
実測ログ上の PICS Type0 パケットには、交差点名称ではなく UTMS_PICS0001 のような ASCII の発信器識別子が入っています。交差点名は Type1 パケット(緯度経度)とバンドルされた全国交差点DBを使って、GPS座標マッチングで名称解決します。
状態表示と空表示のUIラベルは resources/strings/strings.xml で管理しています。ロケール別のディレクトリ(例:resources-eng/strings/strings.xml)を作成することで多言語対応が可能です。
Connect IQ SDK 9.1.0 のシミュレーターは macOS 26 (Tahoe) 以降で ant_main スレッドがクラッシュする既知の問題があります。テストは実機(GPSMAP H1)で行ってください。Garmin が対応 SDK をリリース次第、更新予定です。
本アプリが受信するPICSのBLEアドバタイズは公共インフラから不特定多数に向けて送信される公開電波であり、電波法・電気通信事業法・個人情報保護法の「通信の秘密」には該当しません。本アプリは受信専用であり、信号の送信・改変は行いません。
本プロジェクトは、yumu19/ble-pics-viewer (Python版) の実装およびパケット解析手法を全面的に参考にし、その成果をGarminデバイス向けに移植したものです。貴重な知見とソースコードを公開してくださった原作者の方に、この場を借りて深く感謝申し上げます。
