본문 바로가기

(개인Project)_개발/PLC-PC 연결

[Program][보족] Python OPC-UA 클라이언트 구현 삽질기 : browse path, NodeId, VariantType

반응형

이런 경험 있으신가요?

OPC-UA 서버에 연결해서 노드를 읽으려고 이렇게 했습니다.

node = client.get_node("ns=4;s=PLC.DM[0]")
value = await node.read_value()

자체 제작한 테스트 서버에서는 잘 됐는데, 실제 PLC OPC-UA 서버에 연결하니까 이런 오류가 납니다.

asyncua.ua.uatypes.BadNodeIdUnknown

"ns=4;s=PLC.DM[0]이 없다"는 건데, 분명히 OPC-UA 클라이언트(UaExpert 같은 툴)로 보면 노드가 있습니다.

왜 없다고 할까요?

또 다른 경우도 있습니다.

노드는 찾았는데 값을 쓰려고 하면 이런 오류가 납니다.

asyncua.ua.uaerrors.BadTypeMismatch

PLCLink Phase 4에서 OPC-UA 프로토콜을 추가하면서 두 문제를 모두 만났습니다.


용어 먼저 짚고 넘어갈게요

OPC-UA란

산업 현장에서 여러 브랜드의 PLC와 MES 시스템을 연결하는 표준 통신 프로토콜입니다.

각 제조사마다 다른 독점 프로토콜 대신 OPC-UA를 쓰면 브랜드 상관없이 통신할 수 있어요.

다국어 통역사처럼, OPC-UA 서버가 있으면 클라이언트는 어떤 PLC든 같은 방식으로 데이터를 읽을 수 있습니다.

 

노드 (Node)란

OPC-UA에서 데이터를 담는 단위입니다.

PLC의 DM0 레지스터 하나가 노드 하나에 대응합니다.

노드들이 트리 구조로 연결되어 있어요.

파일 시스템의 폴더와 파일처럼, 노드에 경로로 접근할 수 있습니다.

 

NodeId란

각 노드를 고유하게 식별하는 주소입니다.

ns=4;i=3 같은 숫자 형식이나 ns=4;s=PLC.DM[0] 같은 문자열 형식이 있어요.

문제는 이 NodeId가 서버마다, 심지어 같은 서버의 재시작마다 달라질 수 있다는 겁니다.

 

browse path란

NodeId 대신 트리 구조의 이름을 따라가는 방법입니다.

파일 시스템에서 C:\Users\Gooing\Documents\파일.txt처럼 경로로 찾는 것과 같아요.

NodeId가 바뀌어도 이름(경로)이 같으면 찾을 수 있습니다.

 

VariantType이란

OPC-UA에서 값의 데이터 타입입니다.

정수인지, 실수인지, 불리언인지를 명시합니다.

쓰기를 할 때 이 타입을 맞춰야 하는데, 틀리면 BadTypeMismatch 오류가 납니다.


왜 NodeId 직접 지정이 문제가 되나

asyncua로 서버를 직접 만들어서 테스트하면 노드가 이렇게 만들어집니다.

# asyncua 서버에서 노드 생성
node = await server.nodes.objects.add_variable(
    ua.NodeId(3, 2),      # ns=2, numeric ID=3
    "DM[0]",
    ua.Variant(0, ua.VariantType.Int16)
)

이때 노드의 NodeId는 ns=2;i=3처럼 숫자 형식이 됩니다.

서버를 다시 만들면 ID가 달라져요.

 

자동 생성된 NodeId로 접근하면:

# 이건 내가 만든 테스트 서버에서만 동작
node = client.get_node("ns=2;i=3")

실제 PLC OPC-UA 서버에서는 완전히 다른 NodeId를 씁니다.

Siemens S7-1500이라면 ns=3;s="DB1"."DM[0]" 같은 형식일 수도 있고, 구조가 전혀 다를 수 있어요.

 

문자열 NodeId 템플릿(ns=4;s=PLC.DM[0])을 미리 만들어서 쓰는 방법도 있지만, 이것도 실제 서버의 구조와 맞아야만 합니다.

서버마다 네임스페이스 번호(ns)와 노드 이름 규칙이 다르기 때문에 범용적으로 쓸 수 없어요.

우편함 비유로 설명하면 이렇습니다.

NodeId는 "302호 우편함"처럼 번호로 찾는 방식이에요.

그런데 건물마다 우편함 번호 체계가 다릅니다.

A 건물에서 302호가 나의 우편함이었다고 B 건물에서 302호도 나의 것이 아닌 것처럼, NodeId는 서버마다 다릅니다.


해결 : browse path를 먼저 시도한다

browse path는 이름으로 찾는 방식입니다.

"Objects 폴더 안의 PLC 폴더 안의 DM 폴더 안의 DM[0]"처럼 경로를 따라갑니다.

NodeId가 달라도 이름이 같으면 찾을 수 있어요.

async def get_node_by_browse_path(client, ns, device, address):
    root = client.get_root_node()

    try:
        # browse path 우선 시도
        node = await root.get_child([
            "0:Objects",           # 최상위 Objects 폴더
            f"{ns}:PLC",           # PLC 그룹 노드
            f"{ns}:{device}",      # 디바이스 (DM, CIO 등)
            f"{ns}:{device}[{address}]",  # 개별 노드
        ])
        return node

    except Exception:
        # browse path 실패 시 NodeId 직접 접근으로 폴백
        node_id = f"ns={ns};s=PLC.{device}[{address}]"
        return client.get_node(node_id)

PLCLink에서는 browse path를 먼저 시도하고, 실패하면 NodeId 직접 접근으로 폴백합니다.

이렇게 하면 browse path가 통하는 서버(대부분의 경우)에서는 안정적으로 동작하고, 안 되는 경우에도 NodeId 폴백이 있습니다.


OPC-UA 서버 구조를 먼저 파악해야 한다

browse path가 통하려면 서버의 트리 구조를 알아야 합니다.

실제 PLC OPC-UA 서버에 처음 연결할 때는 UaExpert 같은 OPC-UA 클라이언트 툴로 트리 구조를 먼저 확인하는 것을 권장합니다.

서버 트리 예시 (가상):
Objects
  └─ PLC (ns=2)
       ├─ DM (ns=2)
       │    ├─ DM[0]   → ns=2;i=1001
       │    ├─ DM[1]   → ns=2;i=1002
       │    └─ ...
       └─ CIO (ns=2)
            ├─ CIO[0]  → ns=2;i=2001
            └─ ...

browse path의 각 단계 이름(PLC, DM, DM[0])이 실제 서버 트리의 BrowseName과 일치해야 합니다.

서버마다 이 이름이 다를 수 있어요.

PLCLink에서 asyncua로 자체 OPC-UA 서버를 만들 때는 browse path가 일치하도록 노드 이름을 명시적으로 설정했습니다.

# 서버 노드 생성 시 BrowseName 명시
dm_folder = await plc_node.add_folder(
    ua.NodeId(f"DM", ns),   # NodeId
    ua.QualifiedName("DM", ns)  # BrowseName = browse path에 사용되는 이름
)

두 번째 문제 : 쓰기 시 BadTypeMismatch

노드를 찾는 데 성공했는데 값을 쓰려고 하면 이런 오류가 납니다.

asyncua.ua.uaerrors.BadTypeMismatch

OPC-UA 노드는 자신이 받을 수 있는 데이터 타입이 고정되어 있습니다.

노드가 Int16으로 만들어졌는데 Python int(기본값이 Int64)를 보내면 타입이 맞지 않아서 거부합니다.

해결 방법은 쓰기 전에 현재 노드의 값을 먼저 읽어서 VariantType을 확인하는 것입니다.

async def write_node(node, value):
    # 현재 값을 읽어서 타입 확인
    existing = await node.read_data_value()
    vtype = existing.Value.VariantType

    # 같은 타입으로 변환해서 씀
    typed_value = type(existing.Value.Value)(value)  # 기존 타입으로 캐스팅
    await node.write_value(
        ua.DataValue(ua.Variant(typed_value, vtype))
    )

existing.Value.VariantType으로 현재 타입을 확인하고, 그 타입으로 변환해서 쓰는 방식입니다.

조금 더 견고하게 만들려면:

async def write_node_safe(node, value):
    try:
        existing = await node.read_data_value()
        vtype = existing.Value.VariantType
        existing_val = existing.Value.Value

        # 타입 캐스팅 (예외 처리 포함)
        if isinstance(existing_val, bool):
            typed_value = bool(value)
        elif isinstance(existing_val, int):
            typed_value = int(value)
        elif isinstance(existing_val, float):
            typed_value = float(value)
        else:
            typed_value = value

        await node.write_value(
            ua.DataValue(ua.Variant(typed_value, vtype))
        )
    except Exception as e:
        raise ValueError(f"OPC-UA 쓰기 실패: {e}")

왜 먼저 읽어야 하나

"굳이 먼저 읽어야 하나?"라는 생각이 들 수 있습니다.

그냥 타입을 Int16으로 고정하면 안 될까요?

안 됩니다.

같은 OPC-UA 서버라도 노드마다 타입이 다를 수 있어요.

DM0은 Int16이고, DM1은 UInt32이고, DM2는 Float일 수도 있습니다.

PLCLink가 범용적으로 어떤 서버든 연결해야 하기 때문에 타입을 하드코딩할 수 없어요.

미리 읽어서 타입을 확인하는 것은 약간의 지연이 생기지만, 타입 불일치 오류 없이 안정적으로 쓰기가 됩니다.

쓰기 작업은 읽기에 비해 자주 일어나지 않기 때문에 이 정도 오버헤드는 감수할 만 합니다.


정리

문제 원인 해결
BadNodeIdUnknown NodeId가 서버마다 달라서 browse path로 이름 기반 탐색
browse path도 안 됨 서버 트리 구조가 다름 NodeId 직접 접근으로 폴백
BadTypeMismatch 노드 타입과 쓰려는 값 타입 불일치 쓰기 전에 기존 값 읽어서 VariantType 확인

마치며

OPC-UA는 표준 프로토콜이라 "한 번 만들면 어디서든 된다"고 생각하기 쉽습니다.

실제로는 서버마다 노드 구조, NodeId 생성 방식, 네임스페이스 번호가 달라요.

browse path 방식이 이 문제를 완전히 해결하지는 않습니다.

서버마다 BrowseName 규칙이 다르면 browse path도 맞지 않을 수 있어요.

완전한 해결은 연결할 서버의 트리 구조를 먼저 파악하고, 그에 맞게 경로를 설정하는 것입니다.

PLCLink에서는 browse path 우선 + NodeId 폴백 + VariantType 보존이라는 세 가지 방어 코드로 OPC-UA 서버와의 호환성을 최대한 높였습니다.

반응형