애플리케이션에 시스템 간 파일 전송

즉시 전송

즉시 전송은 예약이나 자동화 없이, API 한 번 호출로 소스 디바이스에서 타깃 디바이스로 파일·폴더를 바로 보내는 방식입니다.

개요

즉시 전송이란

소스 디바이스의 특정 파일/폴더를 타깃 디바이스의 지정 경로로 한 번에 전송합니다. 전송을 생성하면 monitorId가 발급되고, 이 ID로 진행 상태 조회와 일시정지·재개·취소·재시도 같은 제어를 수행합니다.

공통 준비

Base URL

https://app.innorix.com

인증 헤더 — 모든 요청에 다음 헤더가 필요합니다.

헤더설명
Authorization: Bearer {accessToken}로그인으로 발급받은 액세스 토큰(JWT)
x-workspace-id: {workspaceId}작업 대상 워크스페이스 ID
Content-Type: application/json요청 본문 형식

액세스 토큰은 POST /api/auth/login(email, password)으로 발급받으며 응답의 data.user.accessToken을 사용합니다. 만료 시 POST /api/auth/token/refresh(X-Refresh-Token 헤더)로 갱신하고, 장기 연동에는 POST /api/auth/api-keys로 API 키를 발급할 수 있습니다.

디바이스 개념 — 전송의 소스와 타깃은 모두 에이전트가 설치된 디바이스입니다. GET /api/devices로 목록을 조회해 deviceId를 얻습니다. 각 디바이스는 os(windows·linux·mac), 온라인 여부(status) 등의 속성을 가집니다.

전송 대상 지정 — 즉시 전송(POST /api/transfers/manual)은 보낼 항목을 sourcePaths(경로 문자열 배열)로 지정합니다. 자동화·동기화 API는 sourceItem 배열에 hash 식별자로 지정합니다.

필드타입설명
sourcePathsstring[]즉시 전송에서 보낼 경로 목록
sourceItem[].hashstring자동화·동기화 항목 식별자 — {deviceId}_ino_{base64(UTF-8 경로)}
sourceItem[].isDirboolean폴더 여부

폴더 하위 전체를 보낼 때는 sendAllFolder를 사용합니다.

장비 조회 — GET /api/devices/resolve — 이름·IP·MAC으로 deviceId를 즉시 조회합니다(name·ip·mac 중 최소 1개).

json
{
  "status_code": 200,
  "message": "OK",
  "data": {
    "matchCount": 1,
    "devices": [
      {
        "deviceId": "dev_01H8...",
        "name": "OfficePC",
        "ipAddress": "192.168.0.9",
        "osType": "windows",
        "state": 1, "stateName": "CONNECTED", "stateLabel": "연결됨",
        "isConnected": true
      }
    ]
  }
}

장비명이 여러 개 매칭되면 409 + data.candidates[]로 응답하므로, 모호하지 않은 이름을 사용합니다.

폴더 조회(비스트리밍) — GET /api/devices/{deviceId}/files — 폴더 직계 항목을 JSON으로 조회합니다(재귀 검색은 files/search SSE 사용). 응답의 pathfileToken이 함께 제공되며, fileToken은 전송·파일 작업의 항목 식별자로 그대로 사용할 수 있습니다.

json
{
  "status_code": 200,
  "message": "OK",
  "data": {
    "path": "/data", "total": 128, "page": 1, "size": 50, "lastPage": 3,
    "items": [
      {
        "name": "보고서.pdf",
        "path": "/data/보고서.pdf",
        "fileToken": "L2RhdGEv...",
        "isDir": false, "size": 20480,
        "modifiedAt": "2026-08-01T09:12:00Z"
      }
    ]
  }
}

열거형 값 병기 — 단건·상세 응답의 열거형 필드는 정수값과 함께 상수명·라벨을 제공합니다(state/stateName/stateLabel). 연결 여부 같은 파생 플래그(isConnected)도 함께 제공됩니다.

핵심 엔드포인트

목적MethodEndpoint
로그인POST/api/auth/login
토큰 갱신POST/api/auth/token/refresh
디바이스 목록GET/api/devices
디바이스 조회(이름·IP·MAC)GET/api/devices/resolve
경로 용량 미리보기GET/api/devices/{deviceId}/path-stats
소스 파일 검색POST/api/devices/{deviceId}/files/search
폴더 조회(비스트리밍)GET/api/devices/{deviceId}/files
경로 사전 검증POST/api/transfers/validate-path
즉시 전송 생성POST/api/transfers/manual
전송 파일 조회GET/api/transfers/{monitorId}/files
전송 제어POST/api/transfers/{monitorId}/pause · resume · cancel · retry

기본 흐름

  1. 로그인해 액세스 토큰 확보
  2. GET /api/devicessourceDevice, targetDevice 확인
  3. (선택) 파일 검색으로 sourcePaths 구성
  4. (선택) validate-path로 경로 검증
  5. POST /api/transfers/manual로 전송 생성 → monitorId 수신
  6. monitorId로 진행 모니터링·제어

공통 클라이언트 (Java·C#)

Node.js·Python 예제는 파일 하나로 단독 실행됩니다. Java·C# 예제는 공통 클래스(InnorixClient, Java는 Json 포함)의 헬퍼를 함께 씁니다. 아래는 자주 쓰는 부분의 요약이며, 전체 소스는 접기에서 볼 수 있습니다.

// InnorixClient — shared helper the examples `import static`

// ── Auth & call (used by nearly every example)
void   login(String email, String password);           // obtain & store the access token
Object api(String method, String path, Object body);    // JSON call -> returns data; throws on 4xx

// ── Path & status
String  encodePath(String deviceId, String path);       // item token: {deviceId}_ino_{base64(path)}
boolean isTerminal(Object detail);                      // finished (complete/failed) status?
// status codes: COMPLETE=2 · PAUSE=3 · TRANSFERRING=6 · PARTIAL=9 · FAIL=99

// ── JSON helpers
Map<String,Object> obj(Object... kv);   List<Object> arr(Object... items);    // builders
String str(Object n, String key);   int intv(Object n, String key, int def);  // value access
List<Object> pageItems(Object page);    // normalize list-or-{items}/{data}

// ── Env & JSON:  env(key[, def]) · requireEnv(keys...)  |  Json.write() · Json.parse()

1:1 전송

설명

하나의 소스 디바이스에서 하나의 타깃 디바이스로 지정한 파일을 즉시 전송하는 가장 기본 패턴입니다. sourceDevice·targetDevicesourcePaths(경로 문자열 배열)로 소스·타깃과 보낼 항목을 지정하고, monitorId로 완료까지 상태를 확인합니다.

사용 API

목적MethodEndpoint
로그인POST/api/auth/login
전송 생성POST/api/transfers/manual
상태 조회GET/api/transfers/{monitorId}

Request

POST /api/transfers/manual

json
{
  "sourceDevice": "device-source-01",
  "targetDevice": "device-target-01",
  "targetPath": "/data/incoming",
  "sourcePaths": ["/data/report.pdf"],
  "sendAllFolder": false
}
  • sourceDevice·targetDevice에는 deviceId·장비명·IP 중 무엇이든 지정할 수 있습니다.
  • sourcePaths는 보낼 파일·폴더의 경로 문자열 배열입니다(hash 인코딩 불필요).
  • sendAllFolder는 불리언입니다.

ℹ️ sourceDevice/targetDevice에는 deviceId뿐 아니라 장비명·IP도 지정할 수 있으며, 서버가 내부적으로 해석합니다.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

처리 순서

  1. POST /api/auth/login으로 액세스 토큰 확보(data.user.accessToken)
  2. POST /api/transfers/manual 호출 — sourceDevice, targetDevice, targetPath, sourcePathsdata.monitorId 수신
  3. GET /api/transfers/{monitorId}를 폴링해 status 확인 — 2=완료, 4·5·9·99=실패로 종료

구현 예제

"""Example 01 - One-to-one transfer.

Send a single file from a source device to a target device, then poll until it finishes.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "01-1to1-transfer/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

# Transfer status codes and the subset that means "no longer running".
TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    """Minimal JSON API helper shared by every example."""
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def main():
    require_env()

    # Log in once; the same token and workspace header are reused for every call.
    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Send one file from the source device to the target device.
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "sendAllFolder": False,
    })
    monitor_id = transfer["monitorId"]
    print("transfer created", {
        "monitorId": monitor_id,
        "status": transfer.get("status"),
        "statusName": transfer.get("statusName"),
    })

    # Poll the combined active/history detail endpoint until the transfer reaches a terminal status.
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        is_terminal = detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)
        print({
            "monitorId": monitor_id,
            "status": detail.get("status"),
            "statusName": detail.get("statusName"),
            "isTerminal": is_terminal,
            "percent": detail.get("percent", 0),
        })
        if is_terminal:
            if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                raise RuntimeError(detail.get("errorCode") or "Transfer failed")
            print("Transfer completed")
            return
        time.sleep(2)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001 - surface a clean message to the CLI
        print(error, file=sys.stderr)
        sys.exit(1)

파일 수집

설명

여러 소스(지점)의 폴더를 하나의 타깃 경로로 모으는 패턴입니다. 지점마다 대상 폴더를 검색해 전송을 생성하고, 모든 전송의 완료를 함께 집계합니다.

사용 API

목적MethodEndpoint
로그인POST/api/auth/login
전송 생성POST/api/transfers/manual
상태 조회GET/api/transfers/{monitorId}

Request

POST /api/transfers/manual (소스마다 반복)

json
{
  "sourceDevice": "branch-01",
  "targetDevice": "device-target-01",
  "targetPath": "/collect/logs",
  "sourcePaths": ["/var/log"],
  "sendAllFolder": true,
  "transferOptions": { "target-action": "numbering" }
}
  • sourceDevice만 지점마다 바꾸고 targetDevice·targetPath는 고정합니다.
  • transferOptionstarget-action: numbering으로 같은 경로에 모을 때 이름 충돌 시 번호를 붙입니다.
  • 폴더 전체 수집이므로 sendAllFoldertrue입니다.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

처리 순서

  1. POST /api/auth/login으로 액세스 토큰 확보
  2. 지점마다 POST /api/transfers/manual 호출(sourceDevice=지점, targetDevice=본사, sourcePaths=수집 경로) → monitorId 수집
  3. 모든 monitorIdGET /api/transfers/{monitorId}로 폴링해 완료 집계

구현 예제

"""Example 02 - File collect.

Collect the same source path from several branch devices into one HQ device,
running the transfers in parallel and waiting for all of them to finish.

INNORIX_SOURCE_DEVICE / TARGET_DEVICE (and branch names) accept a device ID,
device name, or IP address.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "02-file-collect/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# Comma-separated list of branch (source) devices to collect from.
BRANCH_DEVICES = [
    name.strip()
    for name in os.getenv("INNORIX_BRANCH_DEVICES", "branch-01,branch-02,branch-03").split(",")
    if name.strip()
]
# Single HQ (target) device that receives everything.
HQ_DEVICE = os.getenv("INNORIX_HQ_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/var/log")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/collect/logs")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Start one collect transfer per branch: branch (source) -> HQ (target).
    pending = {}  # branch_name -> monitor_id
    for branch_name in BRANCH_DEVICES:
        transfer = api("POST", "/api/transfers/manual", token, {
            "sourceDevice": branch_name,
            "targetDevice": HQ_DEVICE,
            "targetPath": TARGET_PATH,
            "sourcePaths": [SOURCE_PATH],
            "sendAllFolder": True,
            "transferOptions": {"target-action": "numbering"},
        })
        print(branch_name, {
            "monitorId": transfer["monitorId"],
            "status": transfer.get("status"),
            "targetPath": TARGET_PATH,
        })
        pending[branch_name] = transfer["monitorId"]

    # Poll every branch transfer until each one reaches a terminal status.
    total = len(pending)
    while pending:
        for branch_name, monitor_id in list(pending.items()):
            detail = api("GET", f"/api/transfers/{monitor_id}", token)
            print(branch_name, {
                "status": detail.get("status"),
                "statusName": detail.get("statusName"),
                "percent": detail.get("percent", 0),
            })
            if detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES):
                if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                    raise RuntimeError(f"{branch_name}: {detail.get('errorCode') or 'failed'}")
                del pending[branch_name]
        if pending:
            time.sleep(2)

    print(f"Collected logs from {total} branches")

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

파일 배포

설명

하나의 소스 파일 세트를 여러 타깃 디바이스로 배포하는 패턴입니다. 소스에서 배포 폴더를 한 번 조회한 뒤, 각 타깃으로 동일 패키지를 전송하고 성공·실패를 집계합니다.

사용 API

목적MethodEndpoint
로그인POST/api/auth/login
장비 조회(이름)GET/api/devices/resolve
폴더 조회(비스트리밍)GET/api/devices/{deviceId}/files
전송 생성POST/api/transfers/manual
상태 조회GET/api/transfers/{monitorId}

Request

POST /api/transfers/manual (타깃마다 반복)

json
{
  "sourceDevice": "dev_01H8SRC...",
  "targetDevice": "dev_01H8BRANCH01...",
  "targetPath": "/deploy",
  "sourcePaths": ["/deploy/deployment-package"],
  "sendAllFolder": true,
  "transferOptions": { "target-action": "overwrite" }
}
  • targetDevice만 지점마다 바꾸고 sourceDevice·sourcePaths는 고정합니다.
  • transferOptionstarget-action: overwrite로 타깃의 기존 파일을 덮어씁니다.
  • 폴더 전체 배포이므로 sendAllFoldertrue입니다.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

처리 순서

  1. POST /api/auth/login으로 액세스 토큰 확보
  2. GET /api/devices/resolve?name=으로 소스 deviceId 확보
  3. GET /api/devices/{deviceId}/files로 배포 패키지 폴더를 검색해 경로 확보
  4. 각 타깃마다 POST /api/transfers/manual 호출(targetDevice=지점, sourcePaths=패키지 경로) → 타깃 수만큼 monitorId
  5. monitorIdGET /api/transfers/{monitorId}로 폴링해 성공·실패 집계

구현 예제

"""Example 03 - File distribution.

Distribute one package from a source device to many target devices in parallel,
then wait for all of them and report which succeeded and which failed.

Targets can be supplied via numbered environment variables:
    INNORIX_TARGET_DEVICE_1 / INNORIX_TARGET_PATH_1, _2, _3, ... (up to 50)
If none are set, the DEFAULT_TARGETS below are used.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "03-file-distribution/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE accepts a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
SOURCE_ROOT = os.getenv("INNORIX_SOURCE_ROOT", "/deploy")
PACKAGE_NAME = os.getenv("INNORIX_PACKAGE_NAME", "deployment-package")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

# Used only when no INNORIX_TARGET_DEVICE_N variables are configured.
DEFAULT_TARGETS = [
    {"device": "branch-01", "path": "/deploy/branch-01"},
    {"device": "branch-02", "path": "/deploy/branch-02"},
    {"device": "branch-03", "path": "/deploy/branch-03"},
    {"device": "branch-04", "path": "/deploy/branch-04"},
    {"device": "branch-05", "path": "/deploy/branch-05"},
]

def join_path(root, name):
    normalized_root = str(root or "").replace("\\", "/").rstrip("/")
    normalized_name = str(name or "").replace("\\", "/").lstrip("/")
    if not normalized_root:
        return normalized_name or "/"
    if not normalized_name:
        return normalized_root
    return f"{normalized_root}/{normalized_name}"

SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH") or join_path(SOURCE_ROOT, PACKAGE_NAME)

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def distribution_targets():
    """Read INNORIX_TARGET_DEVICE_N / INNORIX_TARGET_PATH_N pairs, or fall back to DEFAULT_TARGETS."""
    targets = []
    for index in range(1, 51):
        device = os.getenv(f"INNORIX_TARGET_DEVICE_{index}")
        path = os.getenv(f"INNORIX_TARGET_PATH_{index}")
        if not device and not path:
            continue
        if not device or not path:
            raise RuntimeError(
                f"INNORIX_TARGET_DEVICE_{index} and INNORIX_TARGET_PATH_{index} must be set together"
            )
        targets.append({"device": device.strip(), "path": path.replace("\\", "/")})
    return targets if targets else DEFAULT_TARGETS

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Start one transfer per target, reusing the same source package path.
    pending = {}  # target_device -> monitor_id
    for target in distribution_targets():
        transfer = api("POST", "/api/transfers/manual", token, {
            "sourceDevice": SOURCE_DEVICE,
            "targetDevice": target["device"],
            "targetPath": target["path"],
            "sourcePaths": [SOURCE_PATH],
            "sendAllFolder": True,
            "transferOptions": {"target-action": "overwrite"},
        })
        print(target["device"], {
            "monitorId": transfer["monitorId"],
            "status": transfer.get("status"),
            "targetPath": target["path"],
        })
        pending[target["device"]] = transfer["monitorId"]

    # Poll every pending transfer until each one reaches a terminal status.
    results = {}  # target_device -> "success" | error_code
    while pending:
        for target_name, monitor_id in list(pending.items()):
            detail = api("GET", f"/api/transfers/{monitor_id}", token)
            print(target_name, {
                "status": detail.get("status"),
                "statusName": detail.get("statusName"),
                "percent": detail.get("percent", 0),
            })
            if detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES):
                results[target_name] = (
                    "success"
                    if detail.get("status") == TRANSFER_STATUS["transferComplete"]
                    else (detail.get("errorCode") or "failed")
                )
                del pending[target_name]
        if pending:
            time.sleep(2)

    # Split the outcome into succeeded devices and failed devices with their reasons.
    succeeded = [name for name, value in results.items() if value == "success"]
    failed = {name: value for name, value in results.items() if value != "success"}
    print({"succeeded": succeeded, "failed": failed})

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

전송 규칙

설명

하나의 전송이 무엇을 / 어디로 / 어떻게 보낼지 결정하는 요청 구성 요소를 정리합니다. 소스 필터·대상 옵션은 자동화에서 확장 제공합니다.

구분관련 필드세부
무엇을(소스)sourcePaths, sendAllFolder소스 필터
어디로(타깃)targetDevice, targetPath대상 옵션
겹칠 때transferOptions.target-action충돌 처리

사용 API

목적MethodEndpoint
경로 검증POST/api/transfers/validate-path
전송 생성POST/api/transfers/manual

Request

POST /api/transfers/validate-path

json
{
  "sourceDevice": "device-src-001",
  "targetDevice": "device-dst-002",
  "targetPath": "/data/incoming",
  "sourcePaths": ["/data/report.pdf"],
  "sendAllFolder": false
}

Response

json
{
  "status_code": 200,
  "message": "success",
  "data": { "valid": true }
}

처리 순서

  1. POST /api/transfers/validate-path로 소스·타깃·경로 유효성 확인
  2. 규칙(sourcePaths/sendAllFolder/transferOptions.target-action)을 구성해 POST /api/transfers/manual 호출
  3. 응답 data.monitorId로 후속 처리

구현 예제

"""Example 04 - Transfer rules.

Validate the source/target paths first, then start a manual transfer and wait for it to finish.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "04-transfer-rules/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def wait_for_completion(monitor_id, token):
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        is_terminal = detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)
        print({
            "monitorId": monitor_id,
            "status": detail.get("status"),
            "statusName": detail.get("statusName"),
            "isTerminal": is_terminal,
            "percent": detail.get("percent", 0),
        })
        if is_terminal:
            if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                raise RuntimeError(detail.get("errorCode") or "Transfer failed")
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    request = {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "sendAllFolder": False,
    }

    # 1) Validate the transfer request before running it (checks devices and paths).
    validation = api("POST", "/api/transfers/validate-path", token, request)
    print("validate-path", validation)

    # 2) Start the manual transfer using the same request.
    transfer = api("POST", "/api/transfers/manual", token, request)
    print("transfer created", {"monitorId": transfer["monitorId"], "status": transfer.get("status")})

    # 3) Wait until the transfer reaches a terminal status.
    wait_for_completion(transfer["monitorId"], token)
    print("Transfer completed")

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

충돌 처리

설명

타깃 경로에 같은 이름의 파일이 이미 있을 때의 처리 방식입니다. transferOptionstarget-action으로 번호 부여(numbering)·건너뛰기(nosend)·덮어쓰기(overwrite) 중 하나를 지정합니다. 전송 중 실패한 파일은 재시도로 이어받습니다.

사용 API

목적MethodEndpoint
전송 생성POST/api/transfers/manual
실패 재시도POST/api/transfers/{monitorId}/retry

transferOptions.target-action 값:

설명
numbering이름 충돌 시 번호를 붙여 둘 다 보존 ((1), (2))
nosend이미 있으면 보내지 않고 건너뜀
overwrite기존 파일을 덮어씀

Request

POST /api/transfers/manual

json
{
  "sourceDevice": "device-source-01",
  "targetDevice": "device-target-01",
  "targetPath": "/data/incoming",
  "sourcePaths": ["/data/report.pdf"],
  "transferOptions": { "target-action": "numbering" }
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

응답의 data.monitorId로 이후 상태 조회(GET /api/transfers/{monitorId}/files)와 제어(pause·resume·cancel·retry)를 수행합니다.

처리 순서

  1. POST /api/transfers/manualtransferOptions.target-action을 포함해 호출
  2. 응답 data.monitorId 수신
  3. 실패한 파일은 POST /api/transfers/{monitorId}/retry로 재전송

구현 예제

"""Example 05 - Conflict handling.

Send the same file to the same location under each conflict policy and compare the
file-level results.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "05-conflict-handling/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

# Conflict policies to compare: add a number suffix, skip, or overwrite on conflict.
CONFLICT_POLICIES = ["numbering", "nosend", "overwrite"]

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def wait_for_terminal(monitor_id, token):
    """The 'nosend'/'fail' policies may end without success, so wait for any terminal status."""
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        if detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES):
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    for policy in CONFLICT_POLICIES:
        # Same file, same destination - only the conflict policy changes.
        transfer = api("POST", "/api/transfers/manual", token, {
            "sourceDevice": SOURCE_DEVICE,
            "targetDevice": TARGET_DEVICE,
            "targetPath": TARGET_PATH,
            "sourcePaths": [SOURCE_PATH],
            "sendAllFolder": False,
            "transferOptions": {"target-action": policy},
        })
        detail = wait_for_terminal(transfer["monitorId"], token)

        # Inspect the per-file result for this policy. The /files response returns the rows
        # under `children`, and each row names the file/state as sourceFileName / statusName.
        files = api("GET", f"/api/transfers/{transfer['monitorId']}/files?state=any&size=100", token)
        rows = (files or {}).get("children") or (files or {}).get("items") or []
        print(
            f"policy={policy} status={detail.get('status')}",
            [
                {
                    "name": f.get("sourceFileName") or f.get("targetFileName") or f.get("sourceFilePath"),
                    "state": f.get("statusName") or f.get("status"),
                }
                for f in rows
            ],
        )

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

전송 자동화

사람이 매번 호출하지 않아도, 일정·파일 이벤트·워크플로에 따라 전송이 자동으로 실행되도록 구성합니다.

반복 자동화

설명

일정(스케줄)에 따라 같은 전송을 반복 실행합니다. 자동화를 생성하면 automationId가 발급되고, 일시정지·상세 조회로 운영합니다.

사용 API

목적MethodEndpoint
자동화 생성POST/api/automations
자동화 목록GET/api/automations
자동화 상세GET/api/automations/{automationId}/details
자동화 일시정지POST/api/automations/{automationId}/pause

Request

POST /api/automations

json
{
  "name": "Daily Settlement Transfer",
  "transferType": "normal",
  "timezone": "Asia/Seoul",
  "details": [
    {
      "senderId": "device-src-001",
      "receiverId": "device-hq-001",
      "targetPath": "/collect/logs",
      "sourceItem": [{ "hash": "device-src-001_ino_...", "isDir": false }],
      "step": 1,
      "transferOptions": { "noSchedule": false, "target-action": "numbering", "send-fileoption": {} }
    }
  ],
  "schedules": [
    { "type": "day", "startDateType": "now", "hour": "02", "minute": "00", "ampm": "am", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul" }
  ]
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

처리 순서

  1. POST /api/automations 호출 — name, schedules(일정 객체 배열), details(전송 정의 배열), timezone, transferType
  2. 응답 data.automationId 수신
  3. GET /api/automations/{automationId}/details로 세부·진행 확인
  4. 필요 시 POST /api/automations/{automationId}/pause로 일시정지

구현 예제

"""Example 06 - Recurring automation.

Full lifecycle of a scheduled automation: create, read, update the schedule,
list past executions, pause, then delete.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "06-Recurring Automation/example.py"
"""

import base64
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    """UTC timestamp in the same ISO 8601 format Node's Date#toISOString() produces."""
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Schedule: run every day at 02:00 AM to send the previous day's settlement file.
    schedule = {
        "type": "day",
        "startDateType": "now",
        "hour": "02",
        "minute": "00",
        "ampm": "am",
        "startDate": now_iso(),
        "timezone": TIMEZONE,
    }

    # 1) Create the automation.
    created = api("POST", "/api/automations", token, {
        "name": "Daily Settlement Transfer",
        "details": [
            {
                "sourceItem": [{"hash": encode_path(SOURCE_ID, SOURCE_PATH), "isDir": False}],
                "targetPath": TARGET_PATH,
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "transferOptions": {
                    "noSchedule": False,
                    "target-action": "numbering",
                    "send-fileoption": {},
                },
            }
        ],
        "transferType": "normal",
        "timezone": TIMEZONE,
        "schedules": [schedule],
    })
    automation_id = created["automationId"]
    print("automation created", automation_id)

    # 2) Read the automation.
    print("detail", api("GET", f"/api/automations/{automation_id}", token))

    # 3) Update the schedule.
    api("PATCH", f"/api/automations/{automation_id}", token, {
        "name": "Daily Settlement Transfer",
        "isUpdateSchedule": True,
        "schedules": [schedule],
    })

    # 4) List past executions.
    print("executions", api("GET", f"/api/automations/{automation_id}/executions", token))

    # 5) Pause the automation.
    api("POST", f"/api/automations/{automation_id}/pause", token, {"pause": True})

    # 6) Delete the automation (example cleanup).
    api("DELETE", f"/api/automations/{automation_id}", token)
    print("automation deleted", automation_id)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Webhook

설명

전송·자동화 실행을 외부 시스템에 통지합니다. 자동화에 웹훅 프로세서(processors)를 추가하면, 자동화가 실행될 때(events: "Run") 서버가 지정한 URL을 호출합니다.

사용 API

목적MethodEndpoint
웹훅 프로세서 자동화 생성POST/api/automations
자동화 상세(프로세서 포함)GET/api/automations/{automationId}/details

processors[] 필드:

필드타입설명
eventsstring트리거 이벤트 (예: Run)
typestring프로세서 유형 (예: https)
methodstringHTTP 메서드 (예: POST)
urlstring호출할 URL
bodystring요청 본문(선택)

Request

POST /api/automations

json
{
  "name": "Webhook Automation",
  "flowName": "Webhook Automation",
  "transferType": "normal",
  "isUpcoming": false,
  "timezone": "Asia/Seoul",
  "details": [
    {
      "sourceItem": [{ "hash": "device-source-01_ino_...", "isDir": false }],
      "targetPath": "/data/incoming",
      "senderId": "device-source-01",
      "receiverId": "device-target-01",
      "step": 1,
      "transferOptions": { "noSchedule": false, "target-action": "numbering", "send-fileoption": {} }
    }
  ],
  "schedules": [{ "type": "none", "startDateType": "now", "hour": "00", "minute": "00", "ampm": "am", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul" }],
  "processors": [
    { "events": "Run", "type": "https", "method": "POST", "url": "https://example.com/webhook", "body": "" }
  ]
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

처리 순서

  1. POST /api/automationsprocessors(웹훅) 배열을 포함해 자동화 생성
  2. 응답 data.automationId 수신
  3. 자동화 실행 시 서버가 processors[].url을 호출
  4. GET /api/automations/{automationId}/details로 구성된 웹훅 프로세서 확인

구현 예제

"""Example 07 - Webhook.

Create an automation that fires a webhook when it runs. The webhook is defined as a
processor on the automation (events: "Run"), so the server calls your URL on execution.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "07-Webhook/example.py"
"""

import base64
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")
# Webhook processor settings.
WEBHOOK_TYPE = os.getenv("INNORIX_WEBHOOK_TYPE", "https")
WEBHOOK_URL = os.getenv("INNORIX_WEBHOOK_URL", "https://example.com/webhook")
WEBHOOK_METHOD = os.getenv("INNORIX_WEBHOOK_METHOD", "POST")
WEBHOOK_BODY = os.getenv("INNORIX_WEBHOOK_BODY", "")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Create an automation with a webhook processor that fires on run.
    automation = api("POST", "/api/automations", token, {
        "name": "Webhook Automation",
        "flowName": "Webhook Automation",
        "details": [
            {
                "sourceItem": [{"hash": encode_path(SOURCE_ID, SOURCE_PATH), "isDir": False}],
                "targetPath": TARGET_PATH,
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "transferOptions": {
                    "noSchedule": False,
                    "target-action": "numbering",
                    "send-fileoption": {},
                },
            }
        ],
        "transferType": "normal",
        "isUpcoming": False,
        "timezone": TIMEZONE,
        "schedules": [
            {
                "type": "none",
                "startDateType": "now",
                "hour": "00",
                "minute": "00",
                "ampm": "am",
                "startDate": now_iso(),
                "timezone": TIMEZONE,
            }
        ],
        # Webhook: the server calls this URL when the automation runs.
        "processors": [
            {
                "events": "Run",
                "type": WEBHOOK_TYPE,
                "method": WEBHOOK_METHOD,
                "url": WEBHOOK_URL,
                "body": WEBHOOK_BODY,
            }
        ],
    })
    automation_id = automation["automationId"]
    print("automation created", automation_id)

    # Read the automation details, including the configured webhook processor.
    print("details", api("GET", f"/api/automations/{automation_id}/details", token))

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

워크플로

설명

여러 단계를 이어 하나의 흐름으로 구성합니다. 각 홉을 자동화로 만들어 같은 flowId로 묶고, 다음 홉은 triggerAutomation으로 이전 홉에 연결합니다. A→B가 완료되면 서버가 B→C를 자동 실행합니다(클라이언트가 조립하지 않음).

사용 API

목적MethodEndpoint
자동화 생성(홉마다)POST/api/automations
자동화 상세GET/api/automations/{automationId}/details
실행 이력GET/api/automations/{automationId}/executions

Request

POST /api/automations (홉마다, 같은 flowId 공유)

json
{
  "name": "B to C Workflow",
  "flowName": "B-C Workflow",
  "transferType": "normal",
  "isUpcoming": false,
  "timezone": "Asia/Seoul",
  "flowId": "shared-flow-uuid",
  "details": [
    {
      "sourceItem": [{ "hash": "device-middle-01_ino_...", "isDir": false }],
      "targetPath": "/data/incoming",
      "senderId": "device-middle-01",
      "receiverId": "device-target-01",
      "step": 1,
      "transferOptions": { "noSchedule": false, "target-action": "numbering", "send-fileoption": {} }
    }
  ],
  "schedules": [
    { "type": "none", "startDateType": "now", "hour": "00", "minute": "00", "ampm": "am", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul", "triggerAutomation": { "value": "auto-ab-id" } }
  ]
}
  • 모든 홉은 같은 flowId로 하나의 워크플로에 속합니다.
  • 다음 홉의 schedules[].triggerAutomation.value에 이전 홉의 automationId를 지정하면, 이전 홉 완료 후 자동 실행됩니다.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-flow-abc123" }
}

처리 순서

  1. 공유 flowId 생성
  2. A→B 자동화 생성 → automationId 수신
  3. B→C 자동화 생성 시 triggerAutomation.value=A→B automationId, 같은 flowId 지정
  4. GET /api/automations/{automationId}/details·/executions로 단계·실행 확인

구현 예제

"""Example 08 - Workflow (A -> B -> C chain).

Define a relayed transfer as two automations sharing one flowId: A -> B, then B -> C.
Both are marked isUpcoming; B -> C is chained to A -> B via `triggerAutomation`, so the
server runs it automatically once A -> B completes (the client does not orchestrate the
hand-off).

INNORIX_SOURCE_ID / MIDDLE_ID / TARGET_ID must be the exact device IDs - they are encoded
into the path token, so a device name will not work here.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "08-Workflow/example.py"
"""

import base64
import os
import random
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# A -> B -> C: source -> middle (relay) -> target.
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
MIDDLE_ID = os.getenv("INNORIX_MIDDLE_ID", "device-middle-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
MIDDLE_PATH = os.getenv("INNORIX_MIDDLE_PATH", SOURCE_PATH).replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def generate_transfer_id():
    """Generate a transfer id like 'T1234-5678-9012' from time, pid, and randomness."""
    ts = str(int(time.time() * 1000))
    pid = f"{os.getpid() % 1000:03d}"
    rand = f"{random.randint(0, 999):03d}"
    raw = (ts + pid + rand)[-12:]
    return f"T{raw[0:4]}-{raw[4:8]}-{raw[8:12]}"

def generate_flow_id():
    """Flow id derived from a transfer id, e.g. 'F-1234-5678-9012'."""
    return f"F-{generate_transfer_id()[1:]}"

def build_automation(name, flow_name, sender_id, sender_path, receiver_id, flow_id,
                     trigger_automation_id=None):
    """Build one automation body for a single hop (sender -> receiver).

    Pass trigger_automation_id to chain this automation after another one completes.
    """
    schedule = {
        "type": "none",
        "startDateType": "now",
        "hour": "00",
        "minute": "00",
        "ampm": "am",
        "startDate": now_iso(),
        "timezone": TIMEZONE,
    }
    if trigger_automation_id:
        schedule["triggerAutomation"] = {"value": trigger_automation_id}

    return {
        "name": name,
        "flowName": flow_name,
        "details": [
            {
                "sourceItem": [{"hash": encode_path(sender_id, sender_path), "isDir": False}],
                "targetPath": TARGET_PATH,
                "senderId": sender_id,
                "receiverId": receiver_id,
                "step": 1,
                "transferOptions": {
                    "noSchedule": False,
                    "target-action": "numbering",
                    "send-fileoption": {},
                },
            }
        ],
        "transferType": "normal",
        "isUpcoming": True,
        "timezone": TIMEZONE,
        "schedules": [schedule],
        "flowId": flow_id,
    }

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Both hops share one flowId so they belong to the same workflow.
    flow_id = generate_flow_id()

    # 1) A -> B.
    transfer_ab = api("POST", "/api/automations", token, build_automation(
        name="A to B Workflow",
        flow_name="A-B Workflow",
        sender_id=SOURCE_ID,
        sender_path=SOURCE_PATH,
        receiver_id=MIDDLE_ID,
        flow_id=flow_id,
    ))
    automation_id = transfer_ab["automationId"]
    print("A->B automation created", automation_id)

    # 2) B -> C, triggered automatically after the A -> B automation completes.
    transfer_bc = api("POST", "/api/automations", token, build_automation(
        name="B to C Workflow",
        flow_name="B-C Workflow",
        sender_id=MIDDLE_ID,
        sender_path=MIDDLE_PATH,
        receiver_id=TARGET_ID,
        flow_id=flow_id,
        trigger_automation_id=automation_id,
    ))
    print("B->C automation created", transfer_bc["automationId"])

    # 3) Inspect the per-step definition and execution history of the A -> B automation.
    print("details", api("GET", f"/api/automations/{automation_id}/details", token))
    print("executions", api("GET", f"/api/automations/{automation_id}/executions", token))

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

소스 필터

설명

소스에서 보낼 항목을 조건으로 선별합니다. 자동화(POST /api/automations)의 transferOptions에 필터 옵션을 지정하면 확장자·크기·이름 기준으로 서버 측에서 걸러 전송합니다. 폴더를 소스로 지정하면 하위를 조건에 맞게 재귀 수집합니다.

사용 API

목적MethodEndpoint
(선택) 파일 검색POST/api/devices/{deviceId}/files/search
필터 자동화 생성POST/api/automations

필터 관련 transferOptions 필드:

필드타입설명
send-filetype-cusstring포함할 파일 유형 정규식 (예: \\.(log|csv)$)
send-fileoption.fileSizeobject크기 기준 { size, over, equal } (바이트)
send-fileoption.fileNameobject이름 패턴 { name, allow, isMatchCase }allow:false면 제외
savepath / optionPathboolean / string소스 폴더 구조 유지(optionPath: "relative")

Request

POST /api/automations

json
{
  "name": "Source Filter Transfer",
  "flowName": "Source Filter Transfer",
  "transferType": "no_schedule",
  "timezone": "Asia/Seoul",
  "callbackURL": "",
  "details": [
    {
      "sourceItem": [{ "hash": "device-source-01_ino_L3Zhci9sb2c=", "isDir": true }],
      "targetPath": "device-target-01_ino_L2NvbGxlY3QvbG9ncw==",
      "senderId": "device-source-01",
      "receiverId": "device-target-01",
      "step": 1,
      "transferOptions": {
        "noSchedule": true,
        "send-filetype-cus": "\\.(log|csv)$",
        "savepath": true,
        "optionPath": "relative",
        "target-action": "numbering",
        "send-fileoption": {
          "fileSize": { "size": 1024, "over": true, "equal": true },
          "fileName": { "name": "*.tmp", "allow": false, "isMatchCase": false }
        }
      }
    }
  ],
  "schedules": [{ "type": "none", "startDateType": "now", "hour": "05", "minute": "00", "ampm": "pm", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul" }]
}
  • sourceItemhashtargetPath{deviceId}_ino_{base64(경로)} 토큰입니다. senderId·receiverId에는 정확한 deviceId를 지정합니다.
  • send-filetype-cus는 포함할 유형의 정규식, send-fileoption.fileNameallow:false는 제외 패턴입니다.
  • send-fileoption.fileSizeover·equal로 최소 크기 기준을 만듭니다.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

처리 순서

  1. (선택) POST /api/devices/{deviceId}/files/search로 후보 목록 확인
  2. POST /api/automations에 필터 transferOptions를 포함해 호출
  3. 응답 data.automationId로 자동화 상세·진행 확인

구현 예제

"""Example 09 - Source filter.

Create an automation that transfers only files matching a filter (e.g. *.log / *.csv),
while excluding others (e.g. *.tmp) and applying size rules.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "09-source-filter/example.py"
"""

import base64
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/var/log").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/collect/logs").replace("\\", "/")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    automation = api("POST", "/api/automations", token, {
        "name": "Source Filter Transfer",
        "flowName": "Source Filter Transfer",
        "details": [
            {
                "sourceItem": [{"hash": encode_path(SOURCE_ID, SOURCE_PATH), "isDir": True}],
                "targetPath": encode_path(TARGET_ID, TARGET_PATH),
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "transferOptions": {
                    "noSchedule": True,
                    "send-filetype-cus": r"\.(log|csv)$",  # include only .log and .csv files
                    "savepath": True,
                    "optionPath": "relative",  # keep the folder structure under the target path
                    "target-action": "numbering",  # on conflict, append a number
                    "send-fileoption": {
                        # only files >= 1024 bytes
                        "fileSize": {"size": 1024, "over": True, "equal": True},
                        # exclude *.tmp
                        "fileName": {"name": "*.tmp", "allow": False, "isMatchCase": False},
                    },
                },
            }
        ],
        "transferType": "no_schedule",
        "timezone": TIMEZONE,
        "callbackURL": "",
        "schedules": [
            {
                "type": "none",
                "startDateType": "now",
                "hour": "05",
                "minute": "00",
                "ampm": "pm",
                "startDate": now_iso(),
                "timezone": TIMEZONE,
            }
        ],
    })

    print("automation created", {
        "automationId": automation.get("automationId"),
        "flowName": automation.get("flowName") or automation.get("automationName"),
    })

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

대상 옵션

설명

타깃에 파일이 놓이는 방식을 제어합니다. 자동화(POST /api/automations)의 transferOptions로 도착 경로 아래 하위 폴더(savePath), 유지할 소스 경로 단계 수(optionPath), 충돌 처리(target-action)를 지정합니다.

사용 API

목적MethodEndpoint
대상 옵션 자동화 생성POST/api/automations
자동화 조회GET/api/automations/{automationId}

대상 관련 transferOptions 필드:

필드타입설명
savePathstring도착 경로(targetPath) 아래 생성할 상대 하위 폴더 (예: /incoming/2026/08)
optionPathnumber유지할 소스 경로 단계 수
target-actionstring충돌 처리 정책 (예: numbering)
globalobject동일 옵션을 자동화 전체에 적용

Request

POST /api/automations

json
{
  "name": "Target Options Transfer",
  "flowName": "Target Options Transfer",
  "transferType": "no_schedule",
  "timezone": "Asia/Seoul",
  "callbackURL": "",
  "details": [
    {
      "sourceItem": [{ "hash": "device-source-01_ino_L3Zhci9sb2c=", "isDir": true }],
      "targetPath": "device-target-01_ino_L2NvbGxlY3QvbG9ncw==",
      "senderId": "device-source-01",
      "receiverId": "device-target-01",
      "step": 1,
      "transferOptions": { "noSchedule": true, "send-fileoption": {}, "target-action": "numbering", "savePath": "/incoming/2026/08", "optionPath": 3 }
    }
  ],
  "global": { "noSchedule": true, "send-fileoption": {}, "target-action": "numbering", "savePath": "/incoming/2026/08", "optionPath": 3 },
  "schedules": [{ "type": "none", "startDateType": "now", "hour": "05", "minute": "00", "ampm": "pm", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul" }]
}
  • savePath는 전체 경로가 아니라 targetPath 아래에 만들어지는 상대 하위 폴더입니다.
  • optionPath는 유지할 소스 경로 단계 수(정수)입니다.
  • 같은 옵션을 details[].transferOptionsglobal 양쪽에 지정합니다.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

처리 순서

  1. POST /api/automations에 대상 transferOptions(+global)를 포함해 호출
  2. 응답 data.automationId 수신
  3. GET /api/automations/{automationId}로 구성 확인

구현 예제

"""Example 10 - Target options.

Create an automation that controls how files land on the target: a fixed save path,
conflict numbering, and how much of the source path structure to keep.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "10-target-options/example.py"
"""

import base64
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/var/log").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/collect/logs").replace("\\", "/")
# Relative subfolder created under the target path (not a full path), e.g. "/incoming/2026/08".
SAVE_PATH = os.getenv("INNORIX_SAVE_PATH", "/incoming/2026/08")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Target options: fixed save path, conflict numbering, and how many path levels to keep.
    target_options = {
        "noSchedule": True,
        "send-fileoption": {},
        "target-action": "numbering",  # on conflict, append a number
        "savePath": SAVE_PATH,  # subfolder created under the target path (relative)
        "optionPath": 3,  # keep this many levels of the source path
    }

    automation = api("POST", "/api/automations", token, {
        "name": "Target Options Transfer",
        "flowName": "Target Options Transfer",
        "details": [
            {
                "sourceItem": [{"hash": encode_path(SOURCE_ID, SOURCE_PATH), "isDir": True}],
                "targetPath": encode_path(TARGET_ID, TARGET_PATH),
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "transferOptions": target_options,
            }
        ],
        "global": target_options,
        "transferType": "no_schedule",
        "timezone": TIMEZONE,
        "callbackURL": "",
        "schedules": [
            {
                "type": "none",
                "startDateType": "now",
                "hour": "05",
                "minute": "00",
                "ampm": "pm",
                "startDate": now_iso(),
                "timezone": TIMEZONE,
            }
        ],
    })

    print("automation created", {
        "automationId": automation.get("automationId"),
        "flowName": automation.get("flowName") or automation.get("automationName"),
    })

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

파일 동기화

두 위치의 파일 상태를 지속적으로 일치시킵니다. 실시간 감시는 핫 폴더로, 주기적 동기화는 자동화로 구성합니다.

동기화 개요

설명

동기화는 자동화를 transferType: "sync"로 만들어 구성합니다. 각 details 항목에 isSync: truetransferOptions.syncType(1=단방향, 2=양방향)을 지정해 소스와 타깃의 상태를 일치시킵니다.

사용 API

목적MethodEndpoint
동기화 자동화 생성POST/api/automations
자동화 조회GET/api/automations/{automationId}
자동화 삭제DELETE/api/automations/{automationId}

Request

POST /api/automations

json
{
  "name": "Synchronization Overview",
  "transferType": "sync",
  "timezone": "Asia/Seoul",
  "isUpcoming": false,
  "schedules": [],
  "details": [
    {
      "sourceItem": [{ "hash": "device-source-01_ino_L2RhdGEvc2hhcmVk", "filePath": "/data/shared", "isDir": true, "fileSize": 0 }],
      "targetPath": "device-target-01_ino_L2RhdGEvbWlycm9y",
      "senderId": "device-source-01",
      "receiverId": "device-target-01",
      "step": 1,
      "fileCount": 0,
      "folderCount": 1,
      "sizeCount": 0,
      "isSync": true,
      "transferOptions": { "syncType": 1 }
    }
  ]
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

처리 순서

  1. POST /api/automationstransferType: "sync", details[].isSync: true, transferOptions.syncType으로 호출
  2. 응답 data.automationId 수신
  3. GET /api/automations/{automationId}로 동기화 상태 확인, 불필요 시 DELETE

구현 예제

"""Example 11 - Synchronization overview.

A synchronization is an automation whose detail has isSync=true (syncType 1 = one-way).
This example creates one, reads it back, then deletes it.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "11-Synchronization-Overview/example.py"
"""

import base64
import os
import sys
from pathlib import Path
from urllib.parse import quote

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/shared").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/mirror").replace("\\", "/")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # 1) Create a one-way synchronization automation (isSync=true, syncType=1).
    print("Create automation")
    automation = api("POST", "/api/automations", token, {
        "name": "Synchronization Overview",
        "transferType": "sync",
        "timezone": TIMEZONE,
        "isUpcoming": False,
        "schedules": [],
        "details": [
            {
                "sourceItem": [
                    {
                        "hash": encode_path(SOURCE_ID, SOURCE_PATH),
                        "filePath": SOURCE_PATH,
                        "isDir": True,
                        "fileSize": 0,
                    }
                ],
                "targetPath": encode_path(TARGET_ID, TARGET_PATH),
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "fileCount": 0,
                "folderCount": 1,
                "sizeCount": 0,
                "isSync": True,
                "transferOptions": {"syncType": 1},  # 1 = one-way, 2 = two-way
            }
        ],
    })
    automation_id = automation.get("automationId")
    if not automation_id:
        raise RuntimeError("Automation creation response did not include an automationId")
    print("automation created", automation_id)

    # 2) Read the automation back; its status reflects the current sync state.
    print("Get automation")
    print(api("GET", f"/api/automations/{quote(str(automation_id))}", token))

    # 3) Delete the automation (example cleanup).
    print("Delete automation")
    api("DELETE", f"/api/automations/{quote(str(automation_id))}", token)
    print("automation deleted", automation_id)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

실시간

설명

감시 폴더(watch folder)를 동기화 자동화로 만들면, 폴더에 파일이 들어오는 즉시 동기화가 트리거됩니다. 파일 투입은 탐색기 복사 API(POST /api/explorer/copyFile/{deviceId})로 모사할 수 있으며, 이는 UI 탐색기의 붙여넣기와 동일한 호출입니다.

사용 API

목적MethodEndpoint
감시 폴더 자동화 생성POST/api/automations
파일 복사(트리거)POST/api/explorer/copyFile/{deviceId}

처리 순서

  1. POST /api/automationstransferType: "sync", syncType: 1(단방향) 감시 폴더 자동화 생성
  2. 감시 폴더에 파일이 생성·복사되면 동기화가 자동 반영
  3. POST /api/explorer/copyFile/{deviceId}로 감시 폴더에 파일을 붙여넣어 트리거

구현 예제

"""Example 12 - Real-time (watch folder).

Create a one-way watch-folder sync automation, then trigger it by copying a file
into the watched source folder (the same call the UI explorer makes on paste).

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "12-Real-Time/example.py"
"""

import base64
import os
import re
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/watch")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
COPY_SOURCE_PATH = os.getenv("INNORIX_COPY_SOURCE_PATH", "/data/report.pdf")
COPY_SOURCE_IS_DIR = os.getenv("INNORIX_COPY_SOURCE_IS_DIR") == "true"
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation and explorer APIs."""
    if not raw_path:
        return ""
    if "_ino_" in raw_path:
        return raw_path  # already encoded
    normalized = str(raw_path).replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def basename_of(file_path):
    parts = [p for p in re.split(r"[\\/]", file_path) if p]
    return parts[-1] if parts else "item"

def parent_path_of(file_path):
    normalized = file_path.replace("\\", "/")
    index = normalized.rfind("/")
    if index <= 0:
        return f"{normalized[:2]}/" if re.match(r"^[A-Za-z]:", normalized) else "/"
    return normalized[:index]

def now_ms():
    return int(time.time() * 1000)

def main():
    require_env()

    # 1) Log in.
    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]
    print("Logged in successfully")

    # 2) Create a one-way watch-folder sync automation (syncType 1 = one-way).
    automation = api("POST", "/api/automations", token, {
        "transferType": "sync",
        "timezone": TIMEZONE,
        "isUpcoming": False,
        "schedules": [],
        "details": [
            {
                "sourceItem": [
                    {
                        "hash": encode_path(SOURCE_ID, SOURCE_PATH),
                        "filePath": SOURCE_PATH,
                        "isDir": True,
                        "fileSize": 0,
                    }
                ],
                "targetPath": encode_path(TARGET_ID, TARGET_PATH),
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "fileCount": 0,
                "folderCount": 1,
                "sizeCount": 0,
                "transferOptions": {"syncType": 1},  # 1 = one-way, 2 = two-way
            }
        ],
    })
    automation_id = automation.get("automationId") or automation.get("id")
    print("Watch folder automation created:", automation_id)

    # 3) Trigger the sync by copying a file into the watched folder.
    #    This mirrors the payload the UI explorer sends on paste.
    file_name = basename_of(COPY_SOURCE_PATH)
    copy_source_parent_path = parent_path_of(COPY_SOURCE_PATH)
    copy_file_payload = {
        "uuid": f"user{now_ms()}",
        "path": encode_path(SOURCE_ID, SOURCE_PATH),  # destination folder to paste into
        "overwrite": False,
        "listFiles": [
            {
                "name": file_name,
                "size": 0,
                "modificationTime": now_ms(),
                "hash": encode_path(SOURCE_ID, COPY_SOURCE_PATH),
                "filePath": COPY_SOURCE_PATH,
                "isDir": COPY_SOURCE_IS_DIR,
                "isTransfer": False,
                "isWait": False,
                "nextTransfer": None,
                "isNew": True,
                "isAccessPermission": True,
                "phash": encode_path(SOURCE_ID, copy_source_parent_path),
            }
        ],
        "statusInfoUrl": f"/explorer/copyFile/{SOURCE_ID}",
    }

    copy_result = api("POST", f"/api/explorer/copyFile/{SOURCE_ID}", token, copy_file_payload)
    print("copyFile result:", copy_result)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

증분

설명

전체를 매번 보내지 않고, 마지막 동기화 이후 변경된 파일만 전송합니다. 반복 자동화에 변경 기준을 두어 구성합니다.

ℹ️ incremental은 프리뷰 플래그입니다. 요청 형식은 확정되었지만, 실제 델타 계산은 에이전트가 수행하며 서버에 따라 활성화되지 않을 수 있습니다.

사용 API

목적MethodEndpoint
증분 전송 생성POST/api/transfers/manual (incremental: true)
주기 동기화POST/api/automations
상태 조회GET/api/transfers/{monitorId}

처리 순서

  1. POST /api/transfers/manualincremental: true로 변경분만 전송(주기화는 POST /api/automations)
  2. 마지막 동기화 이후 변경된 파일만 선별 전송
  3. GET /api/transfers/{monitorId}로 반영 결과 확인

구현 예제

"""Example 13 - Incremental transfer.

Sync a large folder but send only the files that changed since the last run.

NOTE: `incremental` is a preview flag. The request interface is finalized, but the
actual delta calculation is performed by the agent and may not be active on every server.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "13-Incremental/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/bigfolder")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def wait_for_completion(monitor_id, token):
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        is_terminal = detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)
        print({"monitorId": monitor_id, "status": detail.get("status"), "percent": detail.get("percent", 0)})
        if is_terminal:
            if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                raise RuntimeError(detail.get("errorCode") or "Transfer failed")
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Sync the whole folder daily, but send only changed files via the `incremental` flag.
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "sendAllFolder": True,
        "incremental": True,  # send only the delta
    })
    print("transfer created", {"monitorId": transfer["monitorId"], "status": transfer.get("status")})

    wait_for_completion(transfer["monitorId"], token)
    print("Transfer completed")

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

확장 활용

즉시 전송·자동화·동기화를 조합해 실제 개발 시나리오에 적용하는 유스케이스입니다.

Git 밖 파일

설명

대용량 바이너리·미디어·데이터셋처럼 Git으로 관리하기 어려운 파일을 디바이스 간에 전송·동기화합니다.

사용 API

목적MethodEndpoint
전송 생성POST/api/transfers/manual
주기 동기화POST/api/automations
상태 조회GET/api/transfers/{monitorId}/files

처리 순서

  1. 관리 대상 폴더/파일을 소스 경로로 지정
  2. 1회성은 POST /api/transfers/manual, 지속 관리는 POST /api/automations
  3. GET /api/transfers/{monitorId}/files로 반영 확인

빌드 결과물

설명

CI/CD 파이프라인이 만든 빌드 산출물을 배포 대상 디바이스로 전달합니다. 파이프라인에서 전송을 트리거하고, 완료를 웹훅으로 통지받습니다.

사용 API

목적MethodEndpoint
전송 생성POST/api/transfers/manual
자동 배포POST/api/automations
완료 통지(웹훅)POST/api/automations (processors)

처리 순서

  1. 빌드 완료 후 파이프라인에서 POST /api/transfers/manual 호출(다수 대상은 파일 배포 패턴)
  2. 응답 data.monitorId 수신
  3. 자동화 processors(웹훅)로 배포 완료 통지 수신(Webhook 패턴)

AI·데이터

설명

학습 데이터셋·추론 결과 같은 대용량 데이터를 수집 서버나 처리 노드로 이동합니다. 이동 완료를 트리거로 후속 파이프라인을 연결합니다.

사용 API

목적MethodEndpoint
데이터 수집POST/api/transfers/manual
주기 수집POST/api/automations
후속 트리거(웹훅)POST/api/automations (processors)

처리 순서

  1. 데이터 위치를 sourcePaths로 지정해 POST /api/transfers/manual(수집은 파일 수집 패턴)
  2. 완료 후 data.monitorId로 상태 확인
  3. 자동화 processors(웹훅)로 후속 처리(학습·추론) 트리거(Webhook 패턴)

결과·운영

전송이 생성된 뒤 상태 확인, 전송 제어, 오류 대응, 모니터링, 기록 관리를 다룹니다.

상태·결과

설명

진행 중 전송의 파일별 상태와, 완료된 전송의 결과를 조회합니다.

사용 API

목적MethodEndpoint
전송 상태·진행률GET/api/transfers/{monitorId}
진행 중 파일 조회GET/api/transfers/{monitorId}/files
완료 이력 상세GET/api/transfer-history/{monitorId}
완료 이력 파일GET/api/transfers/{monitorId}/files?state=history

Response

json
{
  "status_code": 200,
  "message": "success",
  "data": {
    "monitorId": "mon-abc123",
    "status": 2, "statusName": "COMPLETED", "statusLabel": "완료",
    "files": [
      {
        "path": "/data/report.pdf",
        "fileToken": "L2RhdGEv...",
        "status": 2, "statusName": "DONE", "statusLabel": "완료",
        "size": 20480
      }
    ]
  }
}

처리 순서

  1. GET /api/transfers/{monitorId}로 전송 상태·진행률 확인 — status 2=완료, 4·5·9·99=실패로 종료. 파일별 진행은 GET /api/transfers/{monitorId}/files
  2. 완료 후 GET /api/transfer-history/{monitorId}로 결과 요약 확인
  3. 파일 단위 상세는 GET /api/transfers/{monitorId}/files?state=history

구현 예제

"""Example 14 - Status & results.

List failed/error transfers for a device over the last 7 days, walking cursor-based pages.

INNORIX_TARGET_ID must be the exact device ID used by the transfer-history query.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "14-status-results/example.py"
"""

import json
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlencode

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
DEVICE_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
PAGE_SIZE = int(os.getenv("INNORIX_PAGE_SIZE", "50"))

# Failure statuses (4 = error, 99 = fail).
TRANSFER_STATUS = {"transferError": 4, "transferFail": 99}

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def page_items(page):
    """The history endpoint may return a list or an {items}/{data} wrapper - normalize it."""
    if isinstance(page, list):
        return page
    if isinstance(page, dict):
        if isinstance(page.get("items"), list):
            return page["items"]
        if isinstance(page.get("data"), list):
            return page["data"]
    return []

def next_cursor(page):
    if isinstance(page, dict):
        return (page.get("pagination") or {}).get("nextCursor")
    return None

def iso_days_ago(days):
    dt = datetime.now(timezone.utc) - timedelta(days=days)
    return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def build_history_path(start_date, status_filter, cursor):
    params = {
        "deviceId": DEVICE_ID,
        "statusFilter": status_filter,
        "startDate": start_date,
        "limit": str(PAGE_SIZE),
    }
    if cursor:
        params["cursor"] = json.dumps(cursor)
    return "/api/transfer-history?" + urlencode(params)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Look at failed/error transfers for this device over the last 7 days.
    start_date = iso_days_ago(7)
    status_filter = ",".join(str(v) for v in [TRANSFER_STATUS["transferError"], TRANSFER_STATUS["transferFail"]])

    # Walk cursor-based pages and collect every failed transfer.
    failures = []
    cursor = None
    while True:
        page = api("GET", build_history_path(start_date, status_filter, cursor), token)
        items = page_items(page)
        failures.extend(items)
        cursor = next_cursor(page)
        if len(items) < PAGE_SIZE:
            break
        if not cursor:
            break

    print(f"Failed transfers in last 7 days: {len(failures)}")
    for item in failures:
        print({
            "monitorId": item.get("monitorId"),
            "status": item.get("status"),
            "statusName": item.get("statusName"),
            "targetPath": item.get("targetPath"),
            "finishedAt": item.get("finishedAt") or item.get("createdAt") or item.get("sortKey"),
        })

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

전송 제어

설명

진행 중인 전송을 일시정지·재개·취소로 제어합니다. 전송을 시작하면 monitorId가 발급되고, 이 ID로 상태를 조회하며 일시정지/재개/취소를 요청합니다. 일시정지·재개는 비동기이므로, 전송이 실제 진행 중(transferring=6)일 때 일시정지하고, 일시정지가 반영된 뒤(transferPause=3) 재개합니다.

사용 API

목적MethodEndpoint
전송 시작POST/api/transfers/manual
상태 조회GET/api/transfers/{monitorId}/automation-detail
일시정지·재개·취소POST/api/transfers/{monitorId}/pause · resume · cancel
  • 제어 액션(pause·resume·cancel)은 본문 없는 POST입니다.

처리 순서

  1. POST /api/transfers/manual로 전송 시작 → monitorId
  2. transferring(6)까지 대기 후 POST .../pause로 일시정지
  3. transferPause(3)까지 대기 후 POST .../resume로 재개(정착 전이면 몇 차례 재시도)
  4. 필요 시 POST .../cancel로 취소

구현 예제

"""Example 15 - Transfer control.

Create a transfer, then pause it, resume it, and once it is running again, cancel it.

Timing note: pause/resume are asynchronous. Send pause only after the transfer is
actually running (status transferring=6), and resume only after the pause has settled
(status transferPause=3). Resuming too early may be rejected while the state is still
settling, so we poll for the right state and retry resume a few times.

Endpoints:
    POST /api/transfers/manual
    GET  /api/transfers/{monitorId}            (transfer status)
    POST /api/transfers/{monitorId}/pause
    POST /api/transfers/{monitorId}/resume
    POST /api/transfers/{monitorId}/cancel

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "15-transfer-control/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferPause": 3,
    "transferError": 4,
    "transferCancel": 5,
    "transferring": 6,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = {
    TRANSFER_STATUS["transferComplete"],
    TRANSFER_STATUS["transferError"],
    TRANSFER_STATUS["transferCancel"],
    TRANSFER_STATUS["transferPartialComplete"],
    TRANSFER_STATUS["transferFail"],
}

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def is_terminal(detail):
    return detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)

def wait_for_status(monitor_id, token, wanted_status):
    """Poll the transfer detail until it reaches wanted_status or any terminal status."""
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        terminal = is_terminal(detail)
        print({
            "monitorId": monitor_id,
            "status": detail.get("status"),
            "statusName": detail.get("statusName"),
            "isTerminal": terminal,
            "percent": detail.get("percent", 0),
        })
        if detail.get("status") == wanted_status or terminal:
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Create the transfer (sourcePaths is the plain-string convenience form of sourceItem).
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
    })
    monitor_id = transfer["monitorId"]
    print("transfer created", {"monitorId": monitor_id, "status": transfer.get("status")})

    # Wait until the transfer is actually running before pausing.
    detail = wait_for_status(monitor_id, token, TRANSFER_STATUS["transferring"])
    if is_terminal(detail):
        print("transfer finished before it could be paused (too small/fast)")
        return

    # Pause, then wait until the pause has settled.
    api("POST", f"/api/transfers/{monitor_id}/pause", token)
    detail = wait_for_status(monitor_id, token, TRANSFER_STATUS["transferPause"])
    if is_terminal(detail):
        print("transfer already finished; nothing to resume")
        return

    # Resume, retrying while the server still reports the pause hasn't settled.
    attempt = 0
    while True:
        try:
            api("POST", f"/api/transfers/{monitor_id}/resume", token)
            break
        except Exception as error:  # noqa: BLE001
            if attempt >= 4:
                raise
            print(f"resume not ready yet ({error}); retrying...")
            attempt += 1
            time.sleep(2)

    # Once the resumed transfer is running again, cancel it.
    detail = wait_for_status(monitor_id, token, TRANSFER_STATUS["transferring"])
    if not is_terminal(detail):
        api("POST", f"/api/transfers/{monitor_id}/cancel", token)

    # Wait until the transfer reaches a terminal status.
    detail = wait_for_status(monitor_id, token, TRANSFER_STATUS["transferCancel"])
    print("final", {"status": detail.get("status"), "statusName": detail.get("statusName")})

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

오류·재시도

설명

실패 원인을 코드로 분류해 확인하고, 실패한 파일만 골라 재전송하거나 여러 전송을 한 번에 취소합니다.

사용 API

목적MethodEndpoint
실패 재시도POST/api/transfers/{monitorId}/retry
다중 취소POST/api/transfers/bulk/cancel
단건 취소POST/api/transfers/{monitorId}/cancel

Request

POST /api/transfers/{monitorId}/retry

json
{
  "filesRetry": ["/data/report.pdf", "/data/image.png"]
}

Response

json
{
  "status_code": 200,
  "message": "success",
  "data": { "monitorId": "mon-abc123", "retried": 2 }
}

처리 순서

  1. GET /api/transfers/{monitorId}/files로 실패 파일 확인
  2. POST /api/transfers/{monitorId}/retryfilesRetry로 재전송
  3. 다수 전송 정리는 POST /api/transfers/bulk/cancel(monitorIds)

구현 예제

"""Example 16 - Errors.

Find the most recent failed transfer, read its error code, and list the per-file
failure reasons, mapping error codes to a human-readable category.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "16-Errors/example.py"
"""

import os
import sys
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")

# Failure statuses (4 = error, 99 = fail).
TRANSFER_STATUS = {"transferError": 4, "transferFail": 99}

# Map an error-code prefix to a human-readable cause.
ERROR_CATEGORY = {
    "NETWORK": "Network",
    "PERMISSION": "Permission",
    "CAPACITY": "Capacity",
}

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def rows(page):
    """Responses may wrap rows as a list or as {items}/{data}/{children} - normalize it."""
    if isinstance(page, list):
        return page
    if isinstance(page, dict):
        for key in ("items", "data", "children"):
            if isinstance(page.get(key), list):
                return page[key]
    return []

def categorize(error_code):
    if not error_code:
        return "Other"
    return ERROR_CATEGORY.get(error_code.split("_")[0], "Other")

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # 1) Find the single most recent failed transfer.
    status_filter = ",".join(str(v) for v in [TRANSFER_STATUS["transferError"], TRANSFER_STATUS["transferFail"]])
    history = api("GET", f"/api/transfer-history?statusFilter={status_filter}&limit=1", token)
    failures = rows(history)
    if not failures:
        print("No failed transfers found")
        return
    monitor_id = failures[0]["monitorId"]

    # 2) Read the transfer detail and classify its error code.
    detail = api("GET", f"/api/transfers/{monitor_id}", token)
    print({
        "monitorId": monitor_id,
        "status": detail.get("status"),
        "errorCode": detail.get("errorCode"),
        "cause": categorize(detail.get("errorCode")),
    })

    # 3) List the failed files and each file's failure reason.
    files = api("GET", f"/api/transfers/{monitor_id}/files?state=any&size=100", token)
    for file in rows(files):
        print({
            "name": file.get("sourceFileName") or file.get("targetFileName") or file.get("sourceFilePath"),
            "state": file.get("statusName") or file.get("status"),
            "errorCode": file.get("errorCode"),
            "cause": categorize(file.get("errorCode")),
        })

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

무결성 검증

설명

전송된 파일이 소스와 동일한지 확인합니다. 전송 생성 시 checkIntegrity: true를 지정하면 서버가 전송 후 파일 체크섬을 검증합니다.

사용 API

목적MethodEndpoint
무결성 검증 전송POST/api/transfers/manual (checkIntegrity: true)
상태 조회GET/api/transfers/{monitorId}

Request

POST /api/transfers/manual

json
{
  "sourceDevice": "device-source-01",
  "targetDevice": "device-target-01",
  "targetPath": "/data/incoming",
  "sourcePaths": ["/data/report.pdf"],
  "checkIntegrity": true
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

처리 순서

  1. POST /api/transfers/manualcheckIntegrity: true를 포함해 전송 생성
  2. GET /api/transfers/{monitorId}로 완료까지 상태 확인
  3. 완료 후 검증 결과(integrityVerified)를 확인 — 불일치 시 재전송으로 보정

구현 예제

"""Example 17 - Integrity verification.

Transfer a file with integrity checking enabled, then wait until it completes.
With `checkIntegrity`, the server verifies the file checksum after transfer.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "17-Integrity Verification/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def wait_for_completion(monitor_id, token):
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        is_terminal = detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)
        print({
            "monitorId": monitor_id,
            "status": detail.get("status"),
            "statusName": detail.get("statusName"),
            "isTerminal": is_terminal,
            "percent": detail.get("percent", 0),
        })
        if is_terminal:
            if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                raise RuntimeError(detail.get("errorCode") or "Transfer failed")
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Start a manual transfer with integrity checking enabled.
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "checkIntegrity": True,  # verify the file checksum after transfer
    })
    print("transfer created", {"monitorId": transfer["monitorId"], "status": transfer.get("status")})

    # Wait until the transfer reaches a terminal status.
    detail = wait_for_completion(transfer["monitorId"], token)
    verified = detail.get("integrityVerified", detail.get("checkIntegrity", True))
    print("Transfer completed", {"integrityVerified": verified})

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

모니터링

설명

전송·자동화·디바이스 상태를 지속적으로 관찰합니다.

사용 API

목적MethodEndpoint
전송 진행GET/api/transfers/{monitorId}/files
자동화 상세GET/api/automations/{automationId}/details
디바이스 연결 상태GET/api/devices/{deviceId}/connectivity

처리 순서

  1. 진행 전송은 GET /api/transfers/{monitorId}/files로 주기 폴링
  2. 자동화는 GET /api/automations/{automationId}/details로 진행률 확인
  3. 디바이스는 GET /api/devices/{deviceId}/connectivity로 온라인 여부 확인

구현 예제

"""Example 18 - Monitoring.

Start a transfer, then poll the active-transfers list and print each one's progress,
speed, and estimated remaining time until the transfer we started finishes.

INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "18-Monitoring/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
POLL_COUNT = int(os.getenv("INNORIX_POLL_COUNT", "10"))
POLL_INTERVAL_MS = int(os.getenv("INNORIX_POLL_INTERVAL_MS", "2000"))

# Status 6 = transferring (currently active); the rest are terminal (no longer running).
TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferring": 6,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = {
    TRANSFER_STATUS["transferComplete"],
    TRANSFER_STATUS["transferError"],
    TRANSFER_STATUS["transferCancel"],
    TRANSFER_STATUS["transferPartialComplete"],
    TRANSFER_STATUS["transferFail"],
}

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def page_items(page):
    """The list endpoint may return a list or an {items}/{data} wrapper - normalize it."""
    if isinstance(page, list):
        return page
    if isinstance(page, dict):
        if isinstance(page.get("items"), list):
            return page["items"]
        if isinstance(page.get("data"), list):
            return page["data"]
    return []

def is_terminal(detail):
    return detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # 1) Start a transfer so there is something to monitor.
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "sendAllFolder": False,
    })
    monitor_id = transfer["monitorId"]
    print("transfer created", {"monitorId": monitor_id, "status": transfer.get("status")})

    # 2) Poll the active-transfers list until the transfer we started reaches a terminal status.
    for tick in range(1, POLL_COUNT + 1):
        active = api("GET", f"/api/transfers?statusFilter={TRANSFER_STATUS['transferring']}&limit=50", token)
        transfers = page_items(active)

        print(f"--- poll {tick}/{POLL_COUNT}: {len(transfers)} active transfers ---")
        for item in transfers:
            print({
                "monitorId": item.get("monitorId"),
                "status": item.get("status"),
                "statusName": item.get("statusName"),
                "percent": item.get("percent", item.get("progress", 0)),
                "transferSpeed": item.get("transferSpeed", 0),
                "estimateTime": item.get("estimateTime", 0),
            })

        # Stop once the transfer we started has finished.
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        if is_terminal(detail):
            print("monitored transfer finished", {
                "status": detail.get("status"),
                "statusName": detail.get("statusName"),
                "percent": detail.get("percent", 0),
            })
            break

        if tick < POLL_COUNT:
            time.sleep(POLL_INTERVAL_MS / 1000)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

감사 기록

설명

전송 이력을 조회·내보내기하여 운영 기록으로 남깁니다.

사용 API

목적MethodEndpoint
이력 상세GET/api/transfer-history/{monitorId}
이력 내보내기(CSV)GET/api/transfer-history/export

처리 순서

  1. 개별 전송 기록은 GET /api/transfer-history/{monitorId}로 조회
  2. 기간·상태·키워드로 필터해 GET /api/transfer-history/export로 CSV 내보내기
    • 쿼리: periodDays, status, searchKeyword, page, size, sort

구현 예제

"""Example 19 - Audit history.

Resolve a device, list its transfer history for the last 30 days, then read the
file-level audit detail for the most recent record.

INNORIX_SOURCE_DEVICE accepts a device ID, device name, or IP address; the resolve
endpoint turns it into the exact device ID used by the history query.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "19-Audit History/example.py"
"""

import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
HISTORY_DAYS = int(os.getenv("INNORIX_HISTORY_DAYS", "30"))

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def page_items(page):
    """The history endpoint may return a list or an {items}/{data} wrapper - normalize it."""
    if isinstance(page, list):
        return page
    if isinstance(page, dict):
        if isinstance(page.get("items"), list):
            return page["items"]
        if isinstance(page.get("data"), list):
            return page["data"]
    return []

def audit_view(records):
    """Keep only the audit-relevant fields for readable output."""
    view = []
    for item in records[:10]:
        view.append({
            "monitorId": item.get("monitorId"),
            "transferId": item.get("transferId") or item.get("id"),
            "sourceDeviceName": item.get("sourceDeviceName"),
            "targetDeviceName": item.get("targetDeviceName"),
            "sourcePath": item.get("sourcePath") or item.get("name") or item.get("path"),
            "targetPath": item.get("targetPath"),
            "status": item.get("status"),
            "statusName": item.get("statusName"),
            "createdAt": item.get("createdAt"),
        })
    return view

def iso_days_ago(days):
    dt = datetime.now(timezone.utc) - timedelta(days=days)
    return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # 1) Resolve a device ID / name / IP into its exact device ID.
    device = api("GET", f"/api/devices/resolve?name={quote(SOURCE_DEVICE)}", token)
    # resolve returns {matchCount, devices: [{deviceId, ...}]}; fall back to a flat deviceId.
    devices = (device or {}).get("devices") or []
    device_id = devices[0].get("deviceId") if devices else (device or {}).get("deviceId")
    if not device_id:
        raise RuntimeError(f'No device matched "{SOURCE_DEVICE}"')
    print("resolved device", {"input": SOURCE_DEVICE, "deviceId": device_id})

    # 2) List the device's transfer history for the last N days.
    start_date = iso_days_ago(HISTORY_DAYS)
    history = api(
        "GET",
        f"/api/transfer-history?deviceId={quote(str(device_id))}&startDate={start_date}&limit=50",
        token,
    )
    records = page_items(history)
    print(f"history records in last {HISTORY_DAYS} days: {len(records)}")
    print(audit_view(records))

    # 3) Read file-level audit detail for the most recent record (if any).
    monitor_id = records[0].get("monitorId") if records else None
    if not monitor_id:
        print("No transfer history record was found; nothing to audit.")
        return
    files = api("GET", f"/api/transfers/{monitor_id}/files?state=any&size=100", token)
    # The /files response returns rows under `children` (sourceFileName / statusName fields).
    file_rows = (files or {}).get("children") or page_items(files)
    print(
        f"file-level audit for {monitor_id}",
        [
            {
                "name": f.get("sourceFileName") or f.get("targetFileName") or f.get("sourceFilePath"),
                "state": f.get("statusName") or f.get("status"),
                "status": f.get("status"),
            }
            for f in file_rows
        ],
    )

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)