Ohara Lab

ese774 Frameによるデバイスサーバー開発

位置づけ:SUSANOOのBL774との関係を踏まえ、本書ではese774 Frameを最初に説明する。ただしgRPCなど他のFrameとの技術的な優劣・上下関係を意味しない。まず動的ディスパッチを基本形として理解し、明示的APIは必要に応じて選ぶ。詳しくは共通開発ガイド。

このページは ese774 Frameの開発手順 であり、特定の機器の操作マニュアルではない。実コードは各工程の根拠として用いる。DeviceClassは通信非依存とし、サーバー側とクライアント側の処理を分ける。導入手順は独立したインストールマニュアルにある。

1. DeviceClassとソース配置

ese774_dummy/
├── dummy_device/
│   ├── dummy_ctrl.py
│   ├── dummy_router.py
│   ├── spec.py
│   ├── sync_dummy_client.py
│   └── async_dummy_client.py
├── dynamic/
│   ├── server.py
│   ├── client_sync.py
│   └── client_async.py
├── dynamic_router/
│   ├── server.py
│   ├── client_async.py
│   └── client_sync.py
├── spec/
│   ├── server.py
│   └── client_sync.py
└── local/
    └── local_device_example.py

dummy_device/ が共通の DeviceClass と通信境界の拡張クラスを置く場所である。dynamic/ は自動ディスパッチ、dynamic_router/ は専用 Router を組み合わせた例、spec/ は Pydantic / ApiSpec による明示 API の例である。local/ はネットワークを使わない直接利用の例である。これらは通信方式ごとに DeviceClass を作り直す構成ではない。

dummy_device/dummy_ctrl.py

from pathlib import Path

class DummyDeviceCtrl:
    """ese774_frame の手動統合試験用ダミーデバイス。"""
    def __init__(self, initial_value: int = 10, logger=None, log_level=None, **kwargs):
        self._value = int(initial_value)
        self._scale = 1.0
        self._logger = logger
        self._files = {
            "hello.txt": b"hello from dummy device\n",
            "data.bin": bytes(range(16)),
        }

    def disconnect(self):
        return None

    def ping(self) -> str:
        return "pong"

    def add(self, a: int, b: int) -> int:
        return a + b

    def mixed(self, a: int, b: float, text: str = "") -> float:
        return float(a) + float(b)

    def get_complex_result(self):
        return {"tuple": (1,2,3), "bytes": b"abc", "nested": {"value": self._value}}

    def dangerous_reset(self):
        self._value = 0
        return True

    def _private_api(self):
        return "private"

    @property
    def value(self) -> int:
        return self._value

    @value.setter
    def value(self, value: int):
        self._value = int(value)

    @property
    def status(self) -> str:
        return "connected"

    def get_scale(self) -> float:
        return self._scale

    def set_scale(self, value: float):
        self._scale = float(value)

    scale = property(get_scale, set_scale)

    def get_file_content(self, filename: str):
        if filename not in self._files:
            raise FileNotFoundError(filename)
        return {"filename": filename, "content": self._files[filename]}

    def download_file(self, filename: str, output_dir: str):
        """ローカル実行時はDeviceを実行しているPCへ保存する。"""
        data = self.get_file_content(filename)
        out = Path(output_dir)
        out.mkdir(parents=True, exist_ok=True)
        path = out / filename
        path.write_bytes(data["content"])
        return str(path)

ping()、add()、value、status は通常の Python インターフェースである。download_file() はDeviceClass を実行しているコンピュータにファイルを保存する。ネットワーク経由でクライアント側へ保存したい場合に、後述の Router とクライアント側オーバーライドが必要になる。dangerous_reset() は存在するが、公開してよい操作とは限らない。

2. 自動ディスパッチ

dynamic/server.py

from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

import os
from ese774_frame.api_server import FastApiServer
from dummy_device.dummy_ctrl import DummyDeviceCtrl


class Config:
    HOST='127.0.0.1'
    PORT=int(os.environ.get('ESE774_DUMMY_PORT','8000'))

server=FastApiServer(device_cls=DummyDeviceCtrl, router_cls=None, config=Config, api_spec=None, object_name='dummy', dispatch_exclude={'dangerous_reset'}, device_kwargs={'initial_value':10}, lifespan_msg_prefix='DUMMY')
if __name__=='__main__':
    server.run(host=Config.HOST, port=Config.PORT)

api_spec=None、router_cls=None が自動ディスパッチの基本構成である。dispatch_exclude={'dangerous_reset'} は、DeviceClass にある操作を公開対象から除外する設定である。device_kwargs は DeviceClass の生成引数であり、通信仕様を DeviceClass に持ち込まない。

dynamic/client_sync.py

from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

import os
PORT=int(os.environ.get('ESE774_DUMMY_PORT','8000'))
from ese774_frame.clients.sync_device_client import SyncDeviceClient
c=SyncDeviceClient(server_ip='127.0.0.1',server_port=PORT,object_name='dummy',api_spec=None)
try:
    assert c.ping()=='pong'
    assert c.add(2,5)==7
    assert c.mixed(1,1.5,'x')==2.5
    z=c.get_complex_result(); assert z['tuple']==(1,2,3) and z['bytes']==b'abc'
    c.value=25; assert c.value==25
    assert c.status=='connected'
    c.scale=3.0; assert c.scale==3.0
    print('DYNAMIC_SYNC_OK')
finally: c.close()

dynamic/client_async.py

from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

import os
PORT=int(os.environ.get('ESE774_DUMMY_PORT','8000'))
import asyncio
from ese774_frame.clients.async_device_client import AsyncDeviceClient
async def main():
    c=AsyncDeviceClient(server_ip='127.0.0.1',server_port=PORT,object_name='dummy',api_spec=None)
    try:
        assert await c.ping()=='pong'
        assert await c.add(2,5)==7
        c.value=40; assert c.value==40
        assert c.status=='connected'
        c.scale=5.0; assert c.scale==5.0
        print('DYNAMIC_ASYNC_OK')
    finally: await c.close()
if __name__=='__main__': asyncio.run(main())

これらの例ではメソッド呼び出しとプロパティの読み書きを同じ DeviceClass の公開インターフェースとして扱う。同期・非同期クライアントの違いは呼び出し方にあり、DeviceClass のメソッドを HTTP 専用に書き換える必要はない。

3. サーバー側Adapterとクライアント側オーバーライド

dummy_device/dummy_router.py

from ese774_frame.routers.device_router import DeviceRouter

class DummyRouterCtrl(DeviceRouter):
    """サーバー/クライアントで意味が変わるAPIだけ override。"""
    def download_file(self, filename: str, output_dir: str):
        data = self._device.get_file_content(filename)
        return {"filename": data["filename"], "content": data["content"], "output_dir": output_dir}

    @property
    def router_name(self) -> str:
        return self.__class__.__name__

DummyRouterCtrl(DeviceRouter) は、クライアントの保存先をサーバー側で処理せず、ファイルの内容を返す。これは機器固有の DummyDeviceCtrl を HTTP 対応クラスに変更するための継承ではない。通信境界で意味が変わる操作を Router 側で変換する例である。

dummy_device/sync_dummy_client.py

from pathlib import Path
from ese774_frame.clients.sync_device_client import SyncDeviceClient

class SyncDummyClient(SyncDeviceClient):
    """remote semantics が異なるAPIだけ client側処理を override。"""
    def download_file(self, filename: str, output_dir: str):
        if self.__dict__.get("_auto_dispatch", False):
            result = self.dispatch("download_file", filename, output_dir)
        else:
            raw = getattr(self, "_download_file_raw")
            result = raw(filename, output_dir)
        path = Path(output_dir) / result["filename"]
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(result["content"])
        return str(path)

dummy_device/async_dummy_client.py

from pathlib import Path
from ese774_frame.clients.async_device_client import AsyncDeviceClient

class AsyncDummyClient(AsyncDeviceClient):
    """remote semantics が異なるAPIだけ client側処理を override。"""
    async def download_file(self, filename: str, output_dir: str):
        if self.__dict__.get("_auto_dispatch", False):
            result = await self.dispatch("download_file", filename, output_dir)
        else:
            raw = getattr(self, "_download_file_raw")
            result = await raw(filename, output_dir)
        path = Path(output_dir) / result["filename"]
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(result["content"])
        return str(path)

クライアント側では download_file() をオーバーライドし、受信した content をクライアントのファイルシステムに書き込む。サーバー側 Router とクライアント側の処理が対になっている点が重要である。例にある _auto_dispatch や _download_file_raw はこの実装の内部呼び出しに関する記述であり、DeviceClass の公開 I/F ではない。別の Frame にそのまま移植するための契約として扱わない。

dynamic_router/client_async.py

from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

import os
PORT=int(os.environ.get('ESE774_DUMMY_PORT','8000'))
import asyncio
from dummy_device.async_dummy_client import AsyncDummyClient
async def main():
    c=AsyncDummyClient(server_ip='127.0.0.1',server_port=PORT,object_name='dummy',api_spec=None)
    try:
        assert await c.ping()=='pong'; assert await c.add(2,5)==7
        c.value=40; assert c.value==40
        assert c.router_name=='DummyRouterCtrl'
        p=await c.download_file('data.bin','client_download_async')
        assert Path(p).read_bytes()==bytes(range(16))
        print('DYNAMIC_ROUTER_ASYNC_OK')
    finally: await c.close()
if __name__=='__main__': asyncio.run(main())

この呼び出し例では、公開された download_file() の意味は「クライアント側にファイルを保存し、そのパスを返す」である。サーバー側の DeviceClass を直接実行した場合と保存場所が異なることを、両側の拡張で吸収している。

4. 明示的API:PydanticとApiSpec

dummy_device/spec.py

from pydantic import BaseModel
from ese774_frame.models.api_spec import ApiSpec

class AddRequest(BaseModel):
    a: int
    b: int
class AddResponse(BaseModel):
    result: int
class PingResponse(BaseModel):
    result: str
class DownloadRequest(BaseModel):
    filename: str
    output_dir: str
class DownloadResponse(BaseModel):
    filename: str
    content: bytes
    output_dir: str

DUMMY_API_SPEC = [
    ApiSpec(request_model=None, response_model=PingResponse, name='ping', object_name='dummy', method='post'),
    ApiSpec(request_model=AddRequest, response_model=AddResponse, name='add', object_name='dummy', method='post'),
    ApiSpec(request_model=None, response_model=None, name='value', object_name='dummy', method='get', kind='property', writable=True),
    ApiSpec(request_model=None, response_model=None, name='status', object_name='dummy', method='get', kind='property', writable=False),
    ApiSpec(request_model=None, response_model=None, name='scale', object_name='dummy', method='get', kind='property', writable=True),
    ApiSpec(request_model=DownloadRequest, response_model=DownloadResponse, name='download_file', object_name='dummy', method='post'),
]

DUMMY_API_SPEC は公開するメソッド・プロパティを列挙し、要求・応答モデルを明示する。自動ディスパッチとの違いは DeviceClass の設計ではなく通信 API の公開方法である。spec/server.py と spec/client_sync.py は例題集に存在するが、実行時の引数とクライアント呼び出しは各サンプルの実装を参照する。

5. パッケージへの統合と型情報

COBOTTA の例だけに依存せず、別の DeviceClass でも同じ構成になることを UR の実装で確認する。以下は src/ur/ 以下のソースパッケージである。build/lib/ur/ はビルド生成物であり、編集するソースの置き場所ではない。また docs/、examples/、tests/ は src/ur/ と並列のリポジトリ直下のディレクトリである。

ur/
├── pyproject.toml
├── src/
│   └── ur/
│       ├── __init__.py
│       ├── ur_ctrl.py                 # DeviceClass 本体
│       ├── config.py
│       └── server_fastapi/
│           ├── __init__.py           # 公開クラス・DeviceProxy 登録
│           ├── spec_ctrl.py          # ApiSpec の定義
│           ├── make.py               # .pyi 生成
│           ├── models/
│           │   ├── common.py
│           │   ├── motion.py
│           │   └── ...
│           ├── routers/
│           │   └── ur_router_ctrl.py  # DeviceRouter の派生
│           └── clients/
│               ├── sync_ur_client.py
│               └── async_ur_client.py
├── examples/
│   ├── run_server.py                # 起動スクリプト
│   ├── client_sample.py
│   └── ur_server.yaml
└── docs/
    ├── ja/
    └── en/

この配置は src/ur/server_fastapi/ が UR パッケージの一部であることを示す。server_fastapi を examples/ の下に置く構成ではない。配布時にはDeviceClassとそのサーバー・クライアント用クラスを同じPythonパッケージからインポートできる。models/ には要求・応答用Pydanticモデル、spec_ctrl.py にはそれらを使ったAPI定義を置く。routers/ と clients/ は、それぞれサーバー側とクライアント側の拡張点である。

サーバー側で DeviceClass を作り直さない

UR の examples/run_server.py は次の構成である(ログ設定と設定ファイルの読み込みは省略)。

from ur import Config, UrCtrl
from ur.server_fastapi import UrRouterCtrl, ur_ctrl_api_spec
from ese774_frame import FastApiServer

server = FastApiServer(
    device_cls=UrCtrl,
    router_cls=UrRouterCtrl,
    config=Config,
    api_spec=ur_ctrl_api_spec,
    logger=logger,
    logger_name=Config.SERVER_LOGGER_NAME,
    lifespan_msg_prefix="UR",
)
server.run(host=Config.SERVER_IP, port=Config.SERVER_PORT)

ここで device_cls=UrCtrl は 既存のDeviceClass本体を指定している。サーバー専用の UrCtrl 派生クラスを追加していない。UrRouterCtrl(DeviceRouter) は device_instance、api_spec、logger、log_level を基底Routerへ渡す薄いラッパであり、装置制御の実装を持たない。サーバー固有のHTTP処理と装置制御を混同しないための構成である。

Pydanticモデルと明示的API

server_fastapi/models/ のモデルを spec_ctrl.py からインポートし、ur_ctrl_api_spec に ApiSpec を列挙する。実ファイルには次のような定義がある。

from ese774_frame.models.api_spec import ApiSpec
from ur.server_fastapi.models.common import ResultResponse, VersionRequest

ur_ctrl_api_spec = [
    ApiSpec(
        name="connect", object_name="ur",
        request_model=None, response_model=ResultResponse,
        method="post", description="connect()",
        summary="UR コントローラへ接続する",
    ),
    ApiSpec(
        name="get_version", object_name="ur",
        request_model=VersionRequest, response_model=ResultResponse,
        method="post", description="get_version(rel_toml_path=None)",
        summary="UR 制御環境のバージョン情報を取得する",
    ),
]

上記は 実際の定義から2件を抜粋したもので、ur_ctrl_api_spec 全体ではない。明示的APIは、DeviceClassを通信仕様に従属させるためではなく、サーバーの公開境界で要求・応答の型と公開対象を定義するために使う。自動ディスパッチの例は本記事の前半を参照する。

クライアント側の継承と上書き

SyncURClient(SyncDeviceClient) と AsyncURClient(AsyncDeviceClient) は、接続先と ur_ctrl_api_spec を基底クライアントに渡す。通常の装置APIをクライアント側へ重複実装しない。同期クライアントの主要部分は次のとおり。

from ese774_frame.clients.sync_device_client import SyncDeviceClient
from ur.server_fastapi.spec_ctrl import ur_ctrl_api_spec

class SyncURClient(SyncDeviceClient):
    def __init__(self, server_ip=None, server_port=None, logger=None, **kwargs):
        # 説明用の抜粋。実際のコンストラクタは config / log_level も受け取る。
        server_ip = server_ip or "127.0.0.1"
        server_port = int(server_port or 8000)
        super().__init__(
            server_ip=server_ip, server_port=server_port,
            api_spec=ur_ctrl_api_spec,
            base_url=f"http://{server_ip}:{server_port}",
            logger=logger,
        )

上のコンストラクタは 呼び出し構造を示す説明用抜粋であり、実装をそのまま複製したものではない。実装上、非同期クライアントは P1、J1、F1、I1、IO1 形式の属性アクセスを get_var / set_var に対応づけるため __getattr__ / __setattr__ を実装する。また disconnect / delete をサーバー側装置の切断に直結させないためのクライアント側処理も持つ。これは クライアントでの見せ方・操作上の調整であり、DeviceClassの責務を移動するものではない。

.pyi は実装ファイルと分離して生成する

server_fastapi/make.py は make_pyi_device_client、make_pyi_device_router、make_pyi_device_proxy を使用し、clients/*.pyi、routers/*.pyi、server_fastapi/__init__.pyi を生成する。実際の *.py は上書きしない。make_pyi_device_proxy では device_class="UrRtde"、aliases=["ur"]、同期・非同期クライアント名を指定している。型情報は通信処理の実装ではなく、開発時のAPI補完・型検査のための成果物として扱う。

URのパッケージ構成では、DeviceClass本体を src/<package>/ に置き、通信層の専用コードを src/<package>/server_fastapi/ にまとめ、起動・操作例をリポジトリ直下の examples/ に置くこと、そして Router・クライアント・API定義・型情報を混在させないことである。これは唯一の必須ディレクトリ名を定める規則ではなく、複数の実装に共通する再利用可能な配置例である。

aandd_reader の実際の配置は src/aandd_reader/aandd_reader.py(DeviceClass)、server_fastapi/(ese774 Frame)、server_grpc/(gRPC Frame)、examples/(起動・操作スクリプト)である。HTTPとgRPCの両方に対応するために、DeviceClassを複製していない。

HTTPサーバーの組み立て

examples/fastapi_server.py の主要部分(Config、logger は同ファイルで初期化):

server = FastApiServer(
    device_cls=AanddReader,
    router_cls=AanddReaderRouter,
    config=Config,
    api_spec=aandd_reader_api_spec,
    device_kwargs={"port": Config.DEVICE_PORT},
    logger=logger,
    logger_name=Config.SERVER_LOGGER_NAME,
    lifespan_msg_prefix="AANDD",
)
server.run(host=Config.SERVER_IP, port=Config.FASTAPI_PORT)

device_cls はクラス、device_kwargs はその生成引数、router_cls はサーバー側の継承クラス、api_spec は明示的公開APIの定義である。これらをDeviceClassの内部に実装しない。

RouterとPydanticモデルの関係

server_fastapi/routers/aandd_reader_router.py の AanddReaderRouter(DeviceRouter) は、受け取った device_instance と api_spec を super().__init__ に渡す。装置固有の処理をRouterで必ず再実装する必要はない。server_fastapi/models/comman.py には次のモデルがある(comman.py は実ファイル名)。

from typing import Any, Optional
from pydantic import BaseModel

class ResultResponse(BaseModel):
    result: Optional[Any]

class SetMeasModeRequest(BaseModel):
    mode: str

spec_ctrl.py は ApiSpec(name="set_meas_mode", object_name="aandd", request_model=SetMeasModeRequest, response_model=ResultResponse, method="post", ...) のようにモデルと公開メソッドを結び付ける。これは明示的API方式の実例であり、自動ディスパッチにモデル定義が必須という意味ではない。

DeviceProxy登録と型定義

server_fastapi/__init__.py は register_device_proxy("AanddReader", async_client_cls=AsyncAanddReaderClient, sync_client_cls=SyncAanddReaderClient, api_spec=aandd_reader_api_spec, default_async_mode=True, aliases=[...]) を呼ぶ。操作例 examples/fastapi_device_proxy.py は from aandd_reader.server_fastapi import DeviceProxy とし、DeviceProxy("AanddReader", config=Config, logger=logger, log_level=log_level, async_mode=True) で作成後、await client.meas() を実行する。

make.py は make_pyi_device_client と make_pyi_device_router による生成に加え、生成された .pyi が実際の継承・補助メソッドを隠さないよう後処理する。make_device_proxy.py は make_pyi_device_proxy で __init__.pyi を生成する。.pyi は実装を置き換えるものではなく、実装と一致させる型宣言である。

参照:同じDeviceClassをgRPCで公開する例はgRPC Frame開発ガイドを参照。

6. Frame間の境界と参照

COBOTTA では同じ分離原則を server_fastapi/models/、routers/、clients/、spec_ctrl.py に展開している。具体的なパッケージ構成と起動・操作例は開発ガイドに示す。gRPC Frame でも DeviceClass と通信境界を分離するが、ここに示した Pydantic / ApiSpec の明示 API 方式は ese774 Frame の例であり、gRPC は自動ディスパッチのみである。gRPC の具体的な実装例は gRPC 側のソースに基づいて別途記述する。