TVチューナー(EX-BCTX2)が時々HDDを見失う対策と録画一覧をDLNAで引っこ抜く話

これは何?

我が家ではモニタ一体型TVではなく、TVチューナーをPCモニタにつなげています。

inajob.hatenablog.jp

製品としてはEX-BCTX2です。

まぁ、いわゆるnasne的なTVチューナーです。

この製品はHDDを内蔵しておらずUSB接続HDDを外付けします。

しかし、どうもこれが曲者で、我が家では、1か月に1度くらいの頻度で接続しているHDDを見失うことがありました。

この現象が起きると、録画済みの番組が見れないだけでなく、録画そのものが行われなくなるため、せっかく見たくて録画予約していた番組の録画が失敗しとても悲しい気持ちになります。

前からこの現象には悩まされてきたのですが、頻度が低かったので特に対策していませんでした。

しかし、先日「ダイの大冒険」を録画し損ねて、とても悔しかったので、その気持ちが熱いうちに対策を考えることにしました。

ステータスページなどは無いのか?

ひとまずHDDを見失うのはあきらめて、「HDDを見失ったことに気付く」というのが出来るのかというアプローチで調査を始めました。

このTVチューナーはIPアドレスを持つので、そこにブラウザでアクセスするなどして、HDDの様子がわかるのであれば、スクレイピングなどで、監視が出来そう・・と思ったのですが、、

表示されるのはファームウェアのバージョンやIPアドレスの情報だけで、HDDの接続状態は含まれていませんでした。

f:id:inajob:20220213203427p:plain

メディアプレイヤーから見ると・・?

このチューナーは専用のソフト(Windowsだと「テレキングリモート」「テレキングプレイ」)を使って視聴するのですが、なぜか録画した番組一覧をWindowsのメディアプレイヤーから取得することが出来ることに気付きました。 (※当たり前ですが、著作権保護機能により映像を見ることはできません)

f:id:inajob:20220213204003p:plain

これは、何らかの標準的なプロトコルで、録画した番組情報を取得できるのだろう、とあたりをつけました。

この方法がわかれば、録画した番組情報が取得でき無くなる==HDDの認識が出来なくなっている、と外部のプログラムから気付くことが出来ます。

UPnPDLNA

ちょっと調べるとすぐにわかりました、まずこのTVチューナーはUPnPでサービスを広報する仕組みがありました。 そして、そのアドレスに対してDLNAと呼ばれる仕様でアクセスすることで、メディアサーバーとしてある程度の操作が出来そうという事がわかりました。

UPnP

まずはUPnPを使ってみます。

丁度我が家のLANにはRaspberryPiが常時起動しているので、ここからコマンドを実行してTVチューナーのサービスを調べてみることにします。

下記のようにgupnp-toolsパッケージに含まれるgssdp-discoverというコマンドでこれを調べることが出来ました。 (USNに含まれるuuidを公開するのがまずいのかどうかよくわかっていないのでマスクしています、多分大丈夫だと思うけど・・)

$ sudo apt-get install gupnp-tools
...
$ gssdp-discover -i eth0 --timeout=3
...
Using network interface eth0
Scanning for all resources
Showing "available" messages
resource available
  USN:      uuid:XXXXXXXXXX::upnp:rootdevice
  Location: http://192.168.1.15:55958/drgd/
  USN:      uuid:XXXXXXXXXX
  Location: http://192.168.1.15:55958/drgd/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-digion-com:device:DragD:1
  Location: http://192.168.1.15:55958/drgd/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-digion-com:service:DragPlusLRManager:1
  Location: http://192.168.1.15:55958/drgd/
resource available
  USN:      uuid:XXXXXXXXXX::upnp:rootdevice
  Location: http://192.168.1.15:55959/public/
resource available
  USN:      uuid:XXXXXXXXXX
  Location: http://192.168.1.15:55959/public/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-iodata-jp:device:NetworkTunerCommand:1
  Location: http://192.168.1.15:55959/public/
resource available
  USN:      uuid:XXXXXXXXXX
  Location: http://192.168.1.15:55247/dms/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-upnp-org:device:MediaServer:1
  Location: http://192.168.1.15:55247/dms/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-upnp-org:service:ContentDirectory:1
  Location: http://192.168.1.15:55247/dms/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-upnp-org:service:ConnectionManager:1
  Location: http://192.168.1.15:55247/dms/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-digion-com:service:X_AccessControl:1
  Location: http://192.168.1.15:55247/dms/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-digion-com:service:X_DeviceConfiguration:1
  Location: http://192.168.1.15:55247/dms/
resource available
  USN:      uuid:XXXXXXXXXX::urn:schemas-dlpa-jp:service:X_DtcpPlus:1
  Location: http://192.168.1.15:55247/dms/
...

これで出てきたHTTPのアドレスにはブラウザでアクセスすることが出来ました。

今回目をつけたのはContentDirectoryというものです。どうも、ここにうまいことリクエストすることで、録画した番組一覧などを取得できるようです。

DLNA

ここからはDLNAという仕様に則って通信を行えば良さそうです。 少し調べるといくつか参考になりそうな情報がありました。 DLNAと言っても、その実態はHTTPのGET/POSTのリクエストのようで、Curlなどでも実行できるものでした。

まずはPOSTのリクエストボディを作ります。

読む限りSOAPのようです(よく知らんけど・・)。

<?xml version="1.0"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <SOAP-ENV:Body>
    <m:Browse xmlns:m="urn:schemas-upnp-org:service:ContentDirectory:1">
      <ObjectID>0</ObjectID>
      <BrowseFlag>BrowseDirectChildren</BrowseFlag>
      <Filter>*</Filter>
      <StartingIndex>0</StartingIndex>
      <RequestedCount>200</RequestedCount>
      <SortCriteria></SortCriteria>
</m:Browse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>

なんとなく意味するところとしては、

  • ContentDirectoryというサービスにBrowseというActionを要求している。
  • ObjectIDというのが操作対象で、0だとルートを指す。
  • BrowseFlagをBrowseDirectChildrenとするとその操作対象の直下にあるリソース一覧を要求することとなる。
  • Filterは*以外の例を見なかった
  • StartIndexは大量のデータの途中から読む際につかうIndex
  • RequestedCountは取得したい数、しかしこの数取れるわけではないようだ
  • SortCriteria は空文字以外の例を見なかった

という感じ。

さて、これを踏まえてCurlのコマンドを呼び出します。

$ curl -v -H "Content-Type: text/xml; charset=\"utf-8\""  -H "SOAPAction: \"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"" --data-binary @request.xml http://192.168.1.15:55247/dms/control/ContentDirectory

ヘッダにContent-TypeSOAPActionというものを付与しないとエラーが返るようでした。

HTTPのアドレスは UPnPで見つけた http://192.168.1.15:55247/dms/ にアクセスするとそれっぽいURLが書いてありました。

さて、これを実行するとルートのリソース直下にある子供のリソースが取得できるはずです・・

*   Trying 192.168.1.15:55247...
* Connected to 192.168.1.15 (192.168.1.15) port 55247 (#0)
> POST /dms/control/ContentDirectory HTTP/1.1
> Host: 192.168.1.15:55247
> User-Agent: curl/7.73.0
> Accept: */*
> Content-Type: text/xml; charset="utf-8"
> SOAPAction: "urn:schemas-upnp-org:service:ContentDirectory:1#Browse"
> Content-Length: 899
>
* upload completely sent off: 899 out of 899 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: Linux/3.0.8 UPnP/1.0 DiXiM/4.0
< Date: Sun, 13 Feb 2022 09:03:53 GMT
< Connection: close
< EXT:
< Content-Type: text/xml; charset="utf-8"
< Transfer-Encoding: chunked
<
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
<Result>&lt;DIDL-Lite xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot;
xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:dlna=&quot;urn:schemas-dlna-org:metadata-1-0/&quot; xmlns:dixim=&quot;urn:schemas-digion
-com:metadata-1-0/dixim/DIDL-Lite/&quot; xmlns:microsoft=&quot;urn:schemas-microsoft-com:WMPNSS-1-0/&quot; xmlns:lamprey=&quot;http://www.lampreynet
works.com/schema/lamprey_1.0/&quot; xmlns:arib=&quot;urn:schemas-arib-or-jp:elements-1-0/&quot; xmlns:dtcp=&quot;urn:schemas-dtcp-com:metadata-1-0/&
quot; xmlns:dlpa=&quot;urn:schemas-dlpa-jp:metadata-1-0/&quot; xmlns:xsrs=&quot;urn:schemas-xsrs-org:metadata-1-0/x_srs/&quot;&gt;
&lt;container id=&quot;root/XXXXXXXXXX/&quot; parentID=&quot;0&quot; restricted=&quot;0&quot; childCount=&quot;11&quot;&gt;
&lt;dc:title&gt;USB2:大容量&lt;/dc:title&gt;
&lt;upnp:containerUpdateID&gt;3802&lt;/upnp:containerUpdateID&gt;
&lt;upnp:class&gt;object.container&lt;/upnp:class&gt;
&lt;/container&gt;&lt;container id=&quot;root/LIVE_TUNER/&quot; parentID=&quot;0&quot; restricted=&quot;1&quot; childCount=&quot;3&quot;&gt;
&lt;dc:title&gt;ライブチューナー&lt;/dc:title&gt;
&lt;upnp:class&gt;object.container&lt;/upnp:class&gt;
&lt;arib:objectType&gt;&lt;/arib:objectType&gt;
&lt;upnp:containerUpdateID&gt;350&lt;/upnp:containerUpdateID&gt;
&lt;/container&gt;&lt;/DIDL-Lite&gt;
</Result>
<NumberReturned>2</NumberReturned>
<TotalMatches>2</TotalMatches>
<UpdateID>3810</UpdateID>
</u:BrowseResponse>
</s:Body>
</s:Envelope>
* Closing connection 0

うげ、なんだこれ・・

と思いつつそれっぽい情報も含まれているので注意深く読んでみます。

どうやらこれは、XMLの中にさらに文字列としてXMLが入ってしまっているようです。

しかしよく見ると「USB2:大容量」「ライブチューナー」という文字が見えます。

この「USB2:大容量」というのは自分が外付けHDDにつけた名前なので、どうやらルートにあるリソースが出ているようです。

さらに良く見るとこの外付けHDDのIDがroot/XXXXXXXXXX/である事もわかります(よくわからないので一応マスクしています。)

次はObijectIDをこの外付けHDDのIDにして、再度同じコマンドを実行すると、今度は外付けHDD内のディレクトリ一覧を取得できました。

ディレクトリ一覧ですが、その中に「すべて」という名前のディレクトリがあり、そのIDで同じコマンドを実行するとどうも、すべての録画データを取得できるようでした。(確かに純正アプリの「テレキングリモート」もそういう挙動をします)

f:id:inajob:20220213212516p:plain

という事で、無事DLNAを使ってTVチューナで録画した番組の一覧を取得することが出来ました。

HDDの死活監視を設定する

という事で、ちょっとやりすぎた感もありますがDLNAを使って外付けHDD内の録画データの一覧を取得できました。

HDDが認識できていないときは、このデータが取得できないはずなので、これを利用して外付けHDDが認識できないときにアラートを上げることが出来るようになりました。

雑に書いたスクリプトはこんな感じ・・

まずはおおもとのシェルスクリプト。これをcronで実行します。

#!/bin/bash

cd `dirname $0`

MAX_RETRY=5
n=0
until [ $n -ge $MAX_RETRY ]
do
# ====================
n=$[$n+1]
curl -v -H "Content-Type: text/xml; charset=\"utf-8\""  -H "SOAPAction: \"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"" --data-binary @request.xml http://192.168.1.15:55247/dms/control/ContentDirectory > get.txt

python check.py && break

echo "ERROR"
sleep 10

# ====================
done

if [ $n -ge $MAX_RETRY ]; then
  echo "failed: ${@}" >&2
  # SLACKなどにHDDが認識できない旨を通知
  exit 1
else
  echo "OK"
fi

リクエストに使うXML文書(ObjectIDはマスクしています)

<?xml version="1.0"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><m:Browse xmlns:m="urn:schemas-upnp-org:service:ContentDirectory:1"><ObjectID xmlns:dt="urn:schemas-microsoft-com:datatypes" dt:dt="string">「外付けHDD内のすべてのリソースを表すID」</ObjectID><BrowseFlag xmlns:dt="urn:schemas-microsoft-com:datatypes" dt:dt="string">BrowseDirectChildren</BrowseFlag><Filter xmlns:dt="urn:schemas-microsoft-com:datatypes" dt:dt="string">*</Filter><StartingIndex xmlns:dt="urn:schemas-microsoft-com:datatypes" dt:dt="ui4">0</StartingIndex><RequestedCount xmlns:dt="urn:schemas-microsoft-com:datatypes" dt:dt="ui4">200</RequestedCount><SortCriteria xmlns:dt="urn:schemas-microsoft-com:datatypes" dt:dt="string"></SortCriteria></m:Browse></SOAP-ENV:Body></SOAP-ENV:Envelope>

curlで得たXMLを解析しちゃんと番組データが入っているかを確認するpythonスクリプト

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import html
import xml.etree.ElementTree as ET

count = 0

with open("get.txt", encoding="utf8") as f:
    s = f.read()
    x = html.unescape(s)
    #print(x)
    root = ET.fromstring(x)
    print(root) # Envelope
    print(root[0]) # Body
    print(root[0][0]) # BrowseResponse
    print(root[0][0][0]) # Result
    print(root[0][0][0][0]) # DITL-Lite
    for item in root[0][0][0][0]:
        print(count, item[0].text) # title
        count = count + 1
    print("count", count)

さて、これで外付けHDDの死活監視を実現できました。

シェルスクリプトで、謎にループしているのは、初回のリクエストは失敗し、その後HDDがスピンアップして安定するとリクエストが成功するためです。

このスクリプトを仕込んでから、まだ1度もHDDの認識を失敗したことがないのでわからないですが、この定期的な外付けHDDのスピンアップのおかげで、認識しなくなる問題も発生頻度が下がっているかもしれません。

蛇足: 録画しているすべての番組を一覧する

ここまでで、表題の問題は解決しましたが、せっかく録画番組一覧が取得できるなら、何かに応用したいな、、と思って取得したデータを見ていました。

しかし、「すべて」のデータを取得しようとしても1度のリクエストでは数十件分のデータしか返却されていないことに気付きました。

どうやら、StartingIndexを取得したデータの個数ずつずらして何度もリクエストすることが必要のようです。

いわゆるWebアプリのAPIの「ページング」的なやつですね。

ここまで来たら、やってしまおうという事で、このページングをすべてたどって、録画データのタイトル一覧を出力するプログラムを書いてみました。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import html
import xml.etree.ElementTree as ET
import urllib.request
import sys

url = 'http://192.168.1.15:55247/dms/control/ContentDirectory'
headers = {
        'Content-Type': 'text/xml; charset="utf-8"',
        'SOAPAction': '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"'
        }

def createRequestBody(objectID, startIndex):
  return '''<?xml version="1.0"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <SOAP-ENV:Body>
    <m:Browse xmlns:m="urn:schemas-upnp-org:service:ContentDirectory:1">
      <ObjectID>%s</ObjectID>
      <BrowseFlag>BrowseDirectChildren</BrowseFlag>
      <Filter>*</Filter>
      <StartingIndex>%s</StartingIndex>
      <RequestedCount>0</RequestedCount>
      <SortCriteria></SortCriteria>
</m:Browse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>''' % (objectID, startIndex)

def browse(objectID, startIndex):
  reqbody = createRequestBody(objectID, startIndex)
  req = urllib.request.Request(
          url,
          reqbody.encode("utf8"),
          method="POST",
          headers = headers
          )
  count = 0
  with urllib.request.urlopen(req) as res:
      s = res.read().decode("utf8")
      x = html.unescape(s)
      root = ET.fromstring(x)
      return root

def getItems(root):
    body = root.findall('{http://schemas.xmlsoap.org/soap/envelope/}Body')[0]
    browseResponse = body.findall('{urn:schemas-upnp-org:service:ContentDirectory:1}BrowseResponse')[0]
    return browseResponse.findall('Result')[0].findall('{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}DIDL-Lite')[0]

root = browse(0, 0)
# find USB disk
usbID = -1
for item in getItems(root):
    if item[0].text.find('USB') != -1:
        usbID = item.attrib['id']

root = browse(usbID, 0)
# find ALL directory
allID = -1
for item in getItems(root):
    if item[0].text.find('すべて') != -1:
        allID = item.attrib['id']

# list all contents
index = 0
while True:
    root = browse(allID, index)
    body = root.findall('{http://schemas.xmlsoap.org/soap/envelope/}Body')[0]
    browseResponse = body.findall('{urn:schemas-upnp-org:service:ContentDirectory:1}BrowseResponse')[0]
    numberReturned = int(browseResponse.findall('NumberReturned')[0].text)
    totalMatches = int(browseResponse.findall('TotalMatches')[0].text)
    # print(index, numberReturned, totalMatches)

    items = getItems(root)
    for i, x in enumerate(items):
        title = x.findall('{http://purl.org/dc/elements/1.1/}title')[0]
        start = x.findall('{http://purl.org/dc/elements/1.1/}date')[0]
        print(index + i, start.text, title.text)
    index = index + numberReturned
    if index + numberReturned == totalMatches:
        break

実行すると、何度もリクエストを行い、すべての録画番組のタイトルを取得します。

f:id:inajob:20220213212235p:plain
出力したデータをgrepして特定の番組を取り出している

まだ残る謎

さて、ここまでTVチューナのDLNA機能を使って、録画番組一覧を取得してきましたが、やっていく中で以下のような疑問が出てきましたが、まだ未解決のままです。

何か情報をご存知の方は、ぜひ教えてください。

  • 「テレキングリモート」から録画番組の削除を行っても、DLNA経由での呼び出しではデータが残り続ける。一度「テレキングリモート」で録画番組の一覧を取得すると(このときものすごく遅い)、以降DLNA経由での呼び出しでもデータが正しく消えるようになる。
  • 「テレキングリモート」のUIでは視聴済みの番組にはマークがつくが、DLNAのレスポンスを見る限りそのようなデータが入っていない

DLNAでできるのかは不明ですが、以下のようなこともやってみたいです

  • 放送が終了しているのに残り続けている繰り返し予約の検知
  • 録画予約の取得
  • 録画予約の実施
  • 録画された番組と繰り返し予約エントリの紐づけの取得

まとめ

ということで、TVチューナーで外付けHDDが突然認識されなくなる問題を、外形監視により検知できるようになりました。 まだ一度も発動していないのでスクリプトにはバグがあるかもしれません。

また、副産物として、TVチューナで録画した番組のタイトル一覧を取得できるようになったので、これも何かに使ってみたいです。

例えば、「テレキングリモート」より一覧性の高い録画番組ビューアとか?(ただし再生はできない・・)

参考

DLNA周りの参考となる記事が少なかったのですが、結局以下のページを丸々真似する感じになりました。

記事を書いてくださった方に感謝します。