Skip to main content

opc_da_client/opc_da/
errors.rs

1use thiserror::Error;
2use windows::core::HRESULT;
3
4/// Result type alias for OPC DA operations.
5pub type OpcResult<T> = Result<T, OpcError>;
6
7/// Centralized error enum for the OPC DA client.
8#[derive(Debug, Error)]
9#[non_exhaustive]
10pub enum OpcError {
11    /// Standard Windows COM/DCOM error.
12    ///
13    /// This variant wraps a [`windows::core::Error`] and provides a friendly
14    /// hint for common OPC-related HRESULT codes.
15    #[error("COM error: {source} ({})", friendly_hresult_hint(.source.code()).unwrap_or("No hint available"))]
16    Com {
17        #[from]
18        source: windows::core::Error,
19    },
20
21    /// Connection-related errors (e.g., host unreachable, resolution failure).
22    #[error("Connection failed: {0}")]
23    Connection(String),
24
25    /// Server-specific errors reported via OPC status codes.
26    #[error("Server error: {0} (0x{1:08X})")]
27    Server(String, u32),
28
29    /// Errors during data type conversion or VARIANT processing.
30    #[error("Data conversion failed: {0}")]
31    Conversion(String),
32
33    /// Operation attempted in an invalid state (e.g., group already exists).
34    #[error("Invalid state: {0}")]
35    InvalidState(String),
36
37    /// Feature not implemented or supported by the target OPC server.
38    #[error("Not implemented: {0}")]
39    NotImplemented(String),
40
41    /// Catch-all for unexpected internal failures.
42    #[error("Internal error: {0}")]
43    Internal(String),
44}
45
46impl From<anyhow::Error> for OpcError {
47    fn from(err: anyhow::Error) -> Self {
48        Self::Internal(err.to_string())
49    }
50}
51
52impl From<tokio::task::JoinError> for OpcError {
53    fn from(err: tokio::task::JoinError) -> Self {
54        Self::Internal(format!("Async task join failed: {err}"))
55    }
56}
57
58impl From<std::num::TryFromIntError> for OpcError {
59    fn from(err: std::num::TryFromIntError) -> Self {
60        OpcError::Conversion(format!("Integer conversion error: {err}"))
61    }
62}
63
64/// Helper to format HRESULT with friendly hints.
65pub fn format_hresult(hr: HRESULT) -> String {
66    let hex = format!("0x{:08X}", hr.0 as u32);
67    match friendly_hresult_hint(hr) {
68        Some(hint) => format!("{hex}: {hint}"),
69        None => hex,
70    }
71}
72
73/// Maps known COM/DCOM error codes to actionable user hints.
74pub fn friendly_hresult_hint(hr: HRESULT) -> Option<&'static str> {
75    match hr.0 as u32 {
76        0x80040112 => Some("Server license does not permit OPC client connections"),
77        0x80080005 => Some("Server process failed to start — check if it is installed and running"),
78        0x80070005 => {
79            Some("Access denied — DCOM launch/activation permissions not configured for this user")
80        }
81        0x800706BA => {
82            Some("RPC server unavailable — the target host may be offline or blocking RPC")
83        }
84        0x800706F4 => Some("COM marshalling error — try restarting the OPC server"),
85        0x80040154 => Some("Server is not registered on this machine"),
86        0x80004003 => Some("Invalid pointer (E_POINTER)"),
87        0xC0040004 => Some("Server rejected write — the item may be read-only (OPC_E_BADRIGHTS)"),
88        0xC0040006 => {
89            Some("Data type mismatch — server cannot convert the written value (OPC_E_BADTYPE)")
90        }
91        0xC0040007 => Some("Item ID not found in server address space (OPC_E_UNKNOWNITEMID)"),
92        0xC0040008 => Some("Item ID syntax is invalid for this server (OPC_E_INVALIDITEMID)"),
93        _ => None,
94    }
95}
96
97/// Maps an [`OpcError`] to a friendly COM hint if it is a COM error.
98pub fn friendly_com_hint(error: &OpcError) -> Option<&'static str> {
99    match error {
100        OpcError::Com { source: e } => friendly_hresult_hint(e.code()),
101        _ => None,
102    }
103}
104
105pub(crate) const E_INVALIDARG_HRESULT: u32 = 0x8007_0057;
106pub(crate) const E_NOTIMPL_HRESULT: u32 = 0x8000_4001;
107pub(crate) const RPC_X_NULL_REF_POINTER_HRESULT: u32 = 0x8007_06F4;
108
109pub(crate) fn com_hresult(error: &OpcError) -> Option<u32> {
110    match error {
111        OpcError::Com { source } => Some(source.code().0 as u32),
112        _ => None,
113    }
114}
115
116pub(crate) fn is_com_hresult(error: &OpcError, expected: u32) -> bool {
117    com_hresult(error) == Some(expected)
118}
119
120pub(crate) fn is_da3_browse_compatibility_error(error: &OpcError) -> bool {
121    is_com_hresult(error, RPC_X_NULL_REF_POINTER_HRESULT)
122        || is_com_hresult(error, E_NOTIMPL_HRESULT)
123}
124
125pub(crate) fn contextual_browse_error(
126    error: OpcError,
127    operation: &str,
128    browse_path: &[String],
129    item_name: Option<&str>,
130) -> OpcError {
131    let path = if browse_path.is_empty() {
132        "<root>".to_string()
133    } else {
134        browse_path
135            .iter()
136            .map(|part| format!("{part:?}"))
137            .collect::<Vec<_>>()
138            .join(" > ")
139    };
140    let item = item_name
141        .map(|name| format!(" item {name:?}"))
142        .unwrap_or_default();
143    let hresult = com_hresult(&error)
144        .map(|value| format!("0x{value:08X}"))
145        .unwrap_or_else(|| "N/A".to_string());
146    let hint = friendly_com_hint(&error).unwrap_or("none");
147    let chain = format!("{error:#}");
148
149    tracing::error!(
150        operation = %operation,
151        browse_path = %path,
152        item_name = %item_name.map_or("<none>", |name| name),
153        hresult = %hresult,
154        hint = %hint,
155        chain = %chain,
156        "OPC browse operation failed"
157    );
158
159    OpcError::Internal(format!(
160        "OPC DA {operation} failed at browse path {path}{item}: {error}"
161    ))
162}
163
164/// Emits a structured `tracing::error!` event with machine-parseable fields.
165///
166/// Extracts the HRESULT code and friendly hint from an [`OpcError`],
167/// and logs them as named fields for aggregation by log analysis tools.
168///
169/// # Arguments
170/// * `error` - The OPC error to log
171/// * `operation` - Name of the operation that failed (e.g., "read_tag_values")
172pub fn log_opc_error(error: &OpcError, operation: &str) {
173    let hresult = com_hresult(error).map(|value| format!("0x{value:08X}"));
174    let hint = friendly_com_hint(error);
175    let chain = format!("{error:#}");
176
177    tracing::error!(
178        operation = %operation,
179        hresult = hresult.as_deref().unwrap_or("N/A"),
180        hint = hint.unwrap_or("none"),
181        chain = %chain,
182        "OPC operation failed"
183    );
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn extracts_com_hresult_and_matches_expected_code() {
192        let error = OpcError::Com {
193            source: windows::core::Error::from_hresult(HRESULT(E_INVALIDARG_HRESULT as i32)),
194        };
195        assert_eq!(com_hresult(&error), Some(E_INVALIDARG_HRESULT));
196        assert!(is_com_hresult(&error, E_INVALIDARG_HRESULT));
197        assert!(!is_com_hresult(&error, 0));
198    }
199
200    #[test]
201    fn non_com_errors_have_no_hresult() {
202        let error = OpcError::Internal("test".to_string());
203        assert_eq!(com_hresult(&error), None);
204        assert!(!is_com_hresult(&error, E_INVALIDARG_HRESULT));
205    }
206
207    #[test]
208    fn da3_browse_fallback_is_limited_to_compatibility_hresult_values() {
209        for hresult in [RPC_X_NULL_REF_POINTER_HRESULT, E_NOTIMPL_HRESULT] {
210            let error = OpcError::Com {
211                source: windows::core::Error::from_hresult(HRESULT(hresult as i32)),
212            };
213            assert!(is_da3_browse_compatibility_error(&error));
214        }
215
216        for hresult in [E_INVALIDARG_HRESULT, 0x8007_0005, 0x8007_06BA] {
217            let error = OpcError::Com {
218                source: windows::core::Error::from_hresult(HRESULT(hresult as i32)),
219            };
220            assert!(!is_da3_browse_compatibility_error(&error));
221        }
222        assert!(!is_da3_browse_compatibility_error(&OpcError::Internal(
223            "not a COM compatibility failure".to_string()
224        )));
225    }
226
227    #[test]
228    fn contextual_browse_error_includes_escaped_path_and_item() {
229        let error = contextual_browse_error(
230            OpcError::Internal("synthetic".to_string()),
231            "GetItemID",
232            &[String::from("FCS0528"), "\u{1}".to_string()],
233            Some("\u{1}"),
234        );
235        assert!(matches!(
236            error,
237            OpcError::Internal(message)
238                if message.contains("\"FCS0528\" > \"\\u{1}\"")
239                    && message.contains("item \"\\u{1}\"")
240                    && message.contains("synthetic")
241        ));
242    }
243}