use thiserror::Error;
use windows::core::HRESULT;
pub type OpcResult<T> = Result<T, OpcError>;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OpcError {
#[error("COM error: {source} ({})", friendly_hresult_hint(.source.code()).unwrap_or("No hint available"))]
Com {
#[from]
source: windows::core::Error,
},
#[error("Connection failed: {0}")]
Connection(String),
#[error("Server error: {0} (0x{1:08X})")]
Server(String, u32),
#[error("Data conversion failed: {0}")]
Conversion(String),
#[error("Invalid state: {0}")]
InvalidState(String),
#[error("Not implemented: {0}")]
NotImplemented(String),
#[error("Internal error: {0}")]
Internal(String),
}
impl From<anyhow::Error> for OpcError {
fn from(err: anyhow::Error) -> Self {
Self::Internal(err.to_string())
}
}
impl From<tokio::task::JoinError> for OpcError {
fn from(err: tokio::task::JoinError) -> Self {
Self::Internal(format!("Async task join failed: {err}"))
}
}
impl From<std::num::TryFromIntError> for OpcError {
fn from(err: std::num::TryFromIntError) -> Self {
OpcError::Conversion(format!("Integer conversion error: {err}"))
}
}
pub fn format_hresult(hr: HRESULT) -> String {
let hex = format!("0x{:08X}", hr.0 as u32);
match friendly_hresult_hint(hr) {
Some(hint) => format!("{hex}: {hint}"),
None => hex,
}
}
pub fn friendly_hresult_hint(hr: HRESULT) -> Option<&'static str> {
match hr.0 as u32 {
0x80040112 => Some("Server license does not permit OPC client connections"),
0x80080005 => Some("Server process failed to start — check if it is installed and running"),
0x80070005 => {
Some("Access denied — DCOM launch/activation permissions not configured for this user")
}
0x800706BA => {
Some("RPC server unavailable — the target host may be offline or blocking RPC")
}
0x800706F4 => Some("COM marshalling error — try restarting the OPC server"),
0x80040154 => Some("Server is not registered on this machine"),
0x80004003 => Some("Invalid pointer (E_POINTER)"),
0xC0040004 => Some("Server rejected write — the item may be read-only (OPC_E_BADRIGHTS)"),
0xC0040006 => {
Some("Data type mismatch — server cannot convert the written value (OPC_E_BADTYPE)")
}
0xC0040007 => Some("Item ID not found in server address space (OPC_E_UNKNOWNITEMID)"),
0xC0040008 => Some("Item ID syntax is invalid for this server (OPC_E_INVALIDITEMID)"),
_ => None,
}
}
pub fn friendly_com_hint(error: &OpcError) -> Option<&'static str> {
match error {
OpcError::Com { source: e } => friendly_hresult_hint(e.code()),
_ => None,
}
}
pub(crate) const E_INVALIDARG_HRESULT: u32 = 0x8007_0057;
pub(crate) fn com_hresult(error: &OpcError) -> Option<u32> {
match error {
OpcError::Com { source } => Some(source.code().0 as u32),
_ => None,
}
}
pub(crate) fn is_com_hresult(error: &OpcError, expected: u32) -> bool {
com_hresult(error) == Some(expected)
}
pub(crate) fn contextual_browse_error(
error: OpcError,
operation: &str,
browse_path: &[String],
item_name: Option<&str>,
) -> OpcError {
let path = if browse_path.is_empty() {
"<root>".to_string()
} else {
browse_path
.iter()
.map(|part| format!("{part:?}"))
.collect::<Vec<_>>()
.join(" > ")
};
let item = item_name
.map(|name| format!(" item {name:?}"))
.unwrap_or_default();
let hresult = com_hresult(&error)
.map(|value| format!("0x{value:08X}"))
.unwrap_or_else(|| "N/A".to_string());
let hint = friendly_com_hint(&error).unwrap_or("none");
let chain = format!("{error:#}");
tracing::error!(
operation = %operation,
browse_path = %path,
item_name = %item_name.map_or("<none>", |name| name),
hresult = %hresult,
hint = %hint,
chain = %chain,
"OPC browse operation failed"
);
OpcError::Internal(format!(
"OPC DA {operation} failed at browse path {path}{item}: {error}"
))
}
pub fn log_opc_error(error: &OpcError, operation: &str) {
let hresult = com_hresult(error).map(|value| format!("0x{value:08X}"));
let hint = friendly_com_hint(error);
let chain = format!("{error:#}");
tracing::error!(
operation = %operation,
hresult = hresult.as_deref().unwrap_or("N/A"),
hint = hint.unwrap_or("none"),
chain = %chain,
"OPC operation failed"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_com_hresult_and_matches_expected_code() {
let error = OpcError::Com {
source: windows::core::Error::from_hresult(HRESULT(E_INVALIDARG_HRESULT as i32)),
};
assert_eq!(com_hresult(&error), Some(E_INVALIDARG_HRESULT));
assert!(is_com_hresult(&error, E_INVALIDARG_HRESULT));
assert!(!is_com_hresult(&error, 0));
}
#[test]
fn non_com_errors_have_no_hresult() {
let error = OpcError::Internal("test".to_string());
assert_eq!(com_hresult(&error), None);
assert!(!is_com_hresult(&error, E_INVALIDARG_HRESULT));
}
#[test]
fn contextual_browse_error_includes_escaped_path_and_item() {
let error = contextual_browse_error(
OpcError::Internal("synthetic".to_string()),
"GetItemID",
&[String::from("FCS0528"), "\u{1}".to_string()],
Some("\u{1}"),
);
assert!(matches!(
error,
OpcError::Internal(message)
if message.contains("\"FCS0528\" > \"\\u{1}\"")
&& message.contains("item \"\\u{1}\"")
&& message.contains("synthetic")
));
}
}