ua-client 1.0.0

Native OPC UA browser/inspector GUI built on async-opcua and egui
Documentation
use std::collections::{HashMap, HashSet, VecDeque};

use opcua::types::{NodeId, ObjectId};

use crate::types::{
    AuthMode, EndpointInfo, LogLine, MethodCallOutcome, MethodSignature, NodeSummary, ReferenceRow,
    SecurityMode, SubscriptionRow, TreeChild, WriteTarget,
};

#[derive(Debug, Clone)]
pub enum MethodCallState {
    Loading {
        node: NodeId,
    },
    Failed {
        node: NodeId,
        error: String,
    },
    Inputs {
        node: NodeId,
        signature: MethodSignature,
        edited: Vec<String>,
        field_errors: Vec<Option<String>>,
        call_error: Option<String>,
    },
    Calling {
        node: NodeId,
        signature: MethodSignature,
        edited: Vec<String>,
    },
    Result {
        node: NodeId,
        signature: MethodSignature,
        edited: Vec<String>,
        outcome: MethodCallOutcome,
    },
}

impl MethodCallState {
    pub fn node(&self) -> &NodeId {
        match self {
            MethodCallState::Loading { node }
            | MethodCallState::Failed { node, .. }
            | MethodCallState::Inputs { node, .. }
            | MethodCallState::Calling { node, .. }
            | MethodCallState::Result { node, .. } => node,
        }
    }
}

#[derive(Debug, Clone)]
pub enum AttributeEditState {
    Loading {
        node: NodeId,
        attr_name: String,
    },
    Failed {
        node: NodeId,
        attr_name: String,
        error: String,
    },
    Inputs {
        node: NodeId,
        attr_name: String,
        target: WriteTarget,
        edited: String,
        field_error: Option<String>,
        write_error: Option<String>,
    },
    Writing {
        node: NodeId,
        attr_name: String,
        target: WriteTarget,
        edited: String,
    },
}

impl AttributeEditState {
    pub fn node(&self) -> &NodeId {
        match self {
            AttributeEditState::Loading { node, .. }
            | AttributeEditState::Failed { node, .. }
            | AttributeEditState::Inputs { node, .. }
            | AttributeEditState::Writing { node, .. } => node,
        }
    }

    pub fn attr_name(&self) -> &str {
        match self {
            AttributeEditState::Loading { attr_name, .. }
            | AttributeEditState::Failed { attr_name, .. }
            | AttributeEditState::Inputs { attr_name, .. }
            | AttributeEditState::Writing { attr_name, .. } => attr_name,
        }
    }
}

const MAX_LOG_LINES: usize = 1000;
const MAX_HISTORY: usize = 20;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
    Disconnected,
    Connecting,
    Connected,
    Disconnecting,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailTab {
    Attributes,
    Events,
    DataChanges,
    Subscriptions,
    References,
}

#[derive(Debug, Default)]
pub struct TreeModel {
    pub children: HashMap<NodeId, Vec<TreeChild>>,
    pub expanded: HashSet<NodeId>,
    pub loading: HashSet<NodeId>,
}

impl TreeModel {
    pub fn clear(&mut self) {
        self.children.clear();
        self.expanded.clear();
        self.loading.clear();
    }
}

pub struct AppModel {
    pub endpoint_url: String,
    pub endpoint_history: Vec<String>,
    pub connection: ConnectionState,
    pub root_node: NodeId,
    pub tree: TreeModel,
    pub selected: Option<NodeId>,
    pub node_summary: Option<NodeSummary>,
    pub active_tab: DetailTab,
    pub references: Option<Vec<ReferenceRow>>,
    pub references_loading: bool,
    pub log: VecDeque<LogLine>,
    pub selected_endpoint: Option<EndpointInfo>,
    pub endpoints_loading: bool,
    pub discovered_endpoints: Option<Vec<EndpointInfo>>,
    pub endpoints_dialog_open: bool,
    pub auth_mode: AuthMode,
    pub auth_username: String,
    pub auth_password: String,
    pub auth_cert_path: String,
    pub auth_key_path: String,
    pub last_selection_paths: HashMap<String, Vec<NodeId>>,
    pub last_connection_selections: HashMap<String, ConnectionPrefs>,
    pub endpoint_mode_filter: SecurityMode,
    pub file_picker_open: bool,
    pub method_call: Option<MethodCallState>,
    pub subscriptions: Vec<SubscriptionRow>,
    pub subscribing: HashSet<NodeId>,
    pub attr_edit: Option<AttributeEditState>,
}

#[derive(Debug, Clone, Default)]
pub struct ConnectionPrefs {
    pub auth_mode: AuthMode,
    pub security_mode: SecurityMode,
    pub username: String,
    pub cert_path: String,
    pub key_path: String,
}

impl Default for AppModel {
    fn default() -> Self {
        Self {
            endpoint_url: "opc.tcp://localhost:4855".to_string(),
            endpoint_history: Vec::new(),
            connection: ConnectionState::Disconnected,
            root_node: NodeId::new(0, ObjectId::RootFolder as u32),
            tree: TreeModel::default(),
            selected: None,
            node_summary: None,
            active_tab: DetailTab::References,
            references: None,
            references_loading: false,
            log: VecDeque::with_capacity(MAX_LOG_LINES),
            selected_endpoint: None,
            endpoints_loading: false,
            discovered_endpoints: None,
            endpoints_dialog_open: false,
            auth_mode: AuthMode::Anonymous,
            auth_username: String::new(),
            auth_password: String::new(),
            auth_cert_path: String::new(),
            auth_key_path: String::new(),
            last_selection_paths: HashMap::new(),
            last_connection_selections: HashMap::new(),
            endpoint_mode_filter: SecurityMode::None,
            file_picker_open: false,
            method_call: None,
            subscriptions: Vec::new(),
            subscribing: HashSet::new(),
            attr_edit: None,
        }
    }
}

impl AppModel {
    pub fn push_log(&mut self, line: LogLine) {
        if self.log.len() == MAX_LOG_LINES {
            self.log.pop_front();
        }
        self.log.push_back(line);
    }

    pub fn reset_session_state(&mut self) {
        self.tree.clear();
        self.selected = None;
        self.node_summary = None;
        self.references = None;
        self.references_loading = false;
        self.method_call = None;
        self.subscriptions.clear();
        self.subscribing.clear();
        self.attr_edit = None;
    }

    pub fn record_successful_connection(&mut self) {
        let url = self.endpoint_url.trim().to_string();
        if url.is_empty() {
            return;
        }
        self.endpoint_history.retain(|u| u != &url);
        self.endpoint_history.insert(0, url.clone());
        self.endpoint_history.truncate(MAX_HISTORY);
        self.last_connection_selections.insert(
            url,
            ConnectionPrefs {
                auth_mode: self.auth_mode,
                security_mode: self.endpoint_mode_filter,
                username: self.auth_username.clone(),
                cert_path: self.auth_cert_path.clone(),
                key_path: self.auth_key_path.clone(),
            },
        );
    }

    pub fn apply_saved_connection_prefs(&mut self) {
        let Some(prefs) = self.last_connection_selections.get(&self.endpoint_url).cloned() else {
            return;
        };
        self.auth_mode = prefs.auth_mode;
        self.endpoint_mode_filter = prefs.security_mode;
        self.auth_username = prefs.username;
        self.auth_cert_path = prefs.cert_path;
        self.auth_key_path = prefs.key_path;
    }
}