IBM Maximo Connector¶
The MaximoConnector integrates Machina with IBM Maximo Manage (EAM),
reading and creating maintenance data via the OSLC/JSON REST API.
Prerequisites¶
- IBM Maximo Manage instance (7.6.0.2+ or Maximo Application Suite)
- API key (recommended), or Basic/MAXAUTH credentials
- Network access to the Maximo OSLC endpoints (
/maximo/oslc/os/)
Installation¶
Configuration¶
from machina.connectors import Maximo
from machina.connectors.cmms import ApiKeyHeaderAuth
from machina.domain.asset import AssetType
connector = Maximo(
url="https://maximo.example.com",
auth=ApiKeyHeaderAuth(header_name="apikey", value="your-api-key"),
asset_type_map={
"PUMPS": AssetType.ROTATING_EQUIPMENT,
"VESSELS": AssetType.STATIC_EQUIPMENT,
"INSTRUMENTS": AssetType.INSTRUMENT,
},
)
await connector.connect()
Capabilities¶
| Capability | Description |
|---|---|
read_assets |
Read asset records (mxasset object structure) |
read_work_orders |
Read work orders — filter by asset_id and/or status (accepts WorkOrderStatus enum or raw Maximo code) |
get_work_order |
Fetch a single work order by wonum |
create_work_order |
Create new work orders |
update_work_order |
Update status, assignee, or description — see How updates are sent |
read_spare_parts |
Read inventory items (mxinventory object structure) — filter by sku; an asset_id filter is refused with ConnectorUnsupportedFilterError (see Known Limitations) |
read_maintenance_plans |
Read PM triggers (mxpm object structure) |
read_maintenance_history |
Read an asset's completed and closed work orders (mxwo, status COMP or CLOSE) |
Convenience methods¶
These methods are available but are not declared as agent-discoverable capabilities:
| Method | Description |
|---|---|
close_work_order(wonum) |
Transition to CLOSED (Maximo CLOSE) via update_work_order |
cancel_work_order(wonum) |
Transition to CANCELLED (Maximo CAN) via update_work_order |
Usage Examples¶
Read assets¶
assets = await connector.read_assets()
for asset in assets:
print(f"{asset.id}: {asset.name} (criticality: {asset.criticality})")
Filter work orders with Machina enum¶
from machina.domain.work_order import WorkOrderStatus
wos = await connector.read_work_orders(
asset_id="PUMP-201",
status=WorkOrderStatus.IN_PROGRESS, # auto-mapped to Maximo "INPRG"
)
Get a single work order¶
wo = await connector.get_work_order("WO-001")
if wo:
print(f"{wo.id}: {wo.failure_mode} — {wo.failure_cause}")
Create a work order¶
from datetime import datetime, timezone
from machina.domain import WorkOrder, WorkOrderType, Priority
wo = WorkOrder(
id="",
type=WorkOrderType.CORRECTIVE,
priority=Priority.HIGH,
asset_id="PUMP-201",
description="Replace mechanical seal",
created_at=datetime.now(tz=timezone.utc),
updated_at=datetime.now(tz=timezone.utc),
)
created = await connector.create_work_order(wo)
print(f"Created: {created.id}")
Update / close a work order¶
from machina.domain.work_order import WorkOrderStatus
updated = await connector.update_work_order(
"WO-001",
status=WorkOrderStatus.COMPLETED,
assigned_to="john.doe",
)
await connector.close_work_order("WO-001")
How reads select attributes¶
Maximo answers a collection query with each member's href and nothing else unless the
query names the attributes it wants in oslc.select. Each read therefore selects the
attributes its mapping uses, plus siteid, which together with the record's number forms
its key:
| Read | Object structure | oslc.select |
|---|---|---|
read_assets, get_asset |
mxasset |
assetnum,siteid,description,location,manufacturer,serialnum,priority,parent,classstructureid,assettype |
read_work_orders, get_work_order, read_maintenance_history |
mxwo |
wonum,siteid,description,status,wopriority,worktype,assetnum,lead,assignedownergroup,failurecode,problemcode,reportdate,changedate |
read_spare_parts |
mxinventory |
itemnum,siteid,location,minlevel,item.description,invbalances{curbal},invcost{avgcost,lastcost} |
read_maintenance_plans |
mxpm |
pmnum,siteid,assetnum,description,frequency,frequnit,status |
- Only documented attributes are named. Each name is documented on the object
structure's main object, a child object (
invbalances{…}) or a related object (item.description), because Maximo may refuse a query that names an attribute the object does not have. ASSET has no model number, and the failure remark is a separate object, soAsset.modelstays empty andWorkOrder.failure_causecomes fromproblemcode. - Null attributes are left out. Maximo omits null attributes from its answer, so a missing attribute reads as the field's default: an empty string, no parent, zero stock, criticality C. A PM with no frequency unit gets no calendar interval rather than its frequency read as days. Only the record's number is required: a record that carries nothing else is read, with every other field at its default.
- Incomplete records end the read. This covers a record without its number
(
assetnum,wonum,itemnumorpmnum), one whose number is not text or a whole number, and a record the mapping cannot read. A member that carries only itshref, as on a page that lost the selection, is such a record. The read raisesConnectorSchemaError(aConnectorError); no partial list is returned. The message names the object structure and the identity attribute, or the error type and, for a value the domain model rejects, the fields rejected:Maximo returned a mxinventory record that cannot be mapped (ValidationError: stock_quantity) — refusing to return a partial result. Amaximo_member_refusedwarning adds the read, the record's position in it and, where it applies, the asset the read is for. Neither the error nor the warning repeats a value from Maximo or a URL from its answer. - Negative balances and costs end the read. A Maximo organization can allow negative
current balances, but
SparePartholds no negative stock, cost or reorder point, and the connector does not report a figure Maximo does not hold. A spare part whose bin balances add up to less than zero, or whose cost orminlevelis negative, is therefore refused like any record the mapping cannot read (ValidationError: stock_quantity,unit_costorreorder_point). A read filtered bysku, through the Python API (read_spare_parts(sku=...)) or the MCP toolmachina_list_spare_parts(its optionalskuparameter), still returns any other item; an unfiltered read fails for as long as any item has a negative balance. Bin balances are added as decimals, and the total is rounded down to whole units, so a total of −0.5 is refused too. - Next pages use Maximo's link. Each next page is requested at the link Maximo returns, which repeats the query; the selection is not added again.
metadataholds the rest. It keeps the selected attributes that no field takes, such assiteid, and the record's rest id asrest_id: the last segment of its URI, whichmaximo_raw_attribute_updatetakes asresource_id. It leaves out OSLC's own keys (href,localref,_imagelibrefand*_collectionrefname the host Maximo sees itself on, and_rowstampis a locking token) and the nested objects or lists no field takes, which in a full resource read are links.rest_idcomes from Maximo's answer and is treated as untrusted. It is kept, decoded, only for a record's URI (…/os/<object structure>/<rest id>) and only if, percent-encoded as one path segment, it cannot leave that segment.maximo_raw_attribute_updatechecks it again, like anyresource_id, encodes it and sends the write to the configuredurl, never to the host inhref. Code that builds a URL from it must percent-encode it the same way (urllib.parse.quote(rest_id, safe="")).- Spare-part reads cost Maximo the most. IBM's
Performance Tips
note that a related attribute in dot notation, such as
item.description, adds a database query for each record returned. On a large inventory, passskuwhere you can.
The selections follow IBM's REST API guide (Selecting) and Maximo's data dictionary. They have not been checked against a live Maximo.
How updates are sent¶
Maximo does not address a record by its wonum. Each record has a URI whose last
segment is a rest id that the server derives from the record's primary key (wonum
and siteid for a work order), and IBM documents an update as a POST to that URI
with an x-method-override: PATCH header. So update_work_order, and
close_work_order / cancel_work_order, which go through it:
- look the order up by
wonum(GET /maximo/oslc/os/mxwo?oslc.where=wonum="WO-001"&oslc.select=wonum,siteid&oslc.pageSize=2&lean=1), following the answer's next pages until it holds two rows; - refuse, before writing anything, a
wonumthat matches no work order, matches work orders in more than one site (awonumis unique only within a site), or whose one match does not name thatwonum(compared without regard to case), as a server or proxy that dropped the filter would answer; - send
POST /maximo/oslc/os/mxwo/{rest id}withx-method-override: PATCHandpatchtype: MERGE(andlean=1, unless the connector was created withlean=False), and accept204 No Contentor200 OK. Withoutpatchtype: MERGE, Maximo deletes the child objects of a listed child collection that the request does not repeat; - read the order back from the same URI (
GET /maximo/oslc/os/mxwo/{rest id}, withlean=1like the update) and return it.
Only the rest id is taken from the URI Maximo returns. Its host is the one Maximo sees
itself on (an internal name behind a proxy), so the update and the read-back go to the
configured url, like every other request. The rest id is sent as one percent-encoded
path segment; a URI that is not a work-order URI, a rest id with an escape that is not
UTF-8, or one that is empty or has an empty, . or .. part between / or \
separators once decoded, is refused with ConnectorError before anything is written.
No error repeats the URI. Each refusal after the lookup is logged at WARNING as
maximo_update_refused, with the wonum and a reason (not_found,
several_sites, other_work_order, no_work_order_uri or invalid_rest_id).
The update POST is retried on 429 only. After a network error, a timeout or a
503 it is not sent again, because the first request may have been applied. The
lookup and the read-back are reads and are retried like any other. If Maximo accepted
the update but the order cannot be read back, ConnectorError says that the update
was applied and must not be repeated, and work_order_update_not_read_back is logged
at WARNING with the error type.
The maximo_raw_attribute_update MCP vendor tool
writes through the same helper, so it sends the same POST to the rest id it is given.
Sources: the IBM Maximo REST API guide,
Create and update
and Selecting,
and update() / merge() in IBM's reference Java client,
MaximoConnector.java.
Asset Type Mapping¶
Maximo does not expose a direct equipment-type field. By default, all
assets are classified as ROTATING_EQUIPMENT. Provide an
asset_type_map to override based on your Maximo classstructureid
(or assettype) values:
from machina.domain.asset import AssetType
connector = Maximo(
url="https://maximo.example.com",
auth=auth,
asset_type_map={
"PUMPS": AssetType.ROTATING_EQUIPMENT,
"VESSELS": AssetType.STATIC_EQUIPMENT,
"INSTRUMENTS": AssetType.INSTRUMENT,
"MOTORS": AssetType.ELECTRICAL,
},
)
Unmapped values fall back to ROTATING_EQUIPMENT.
Entity Mapping¶
| Maximo Field | Machina Field |
|---|---|
assetnum |
Asset.id |
description |
Asset.name |
location |
Asset.location |
manufacturer |
Asset.manufacturer |
serialnum |
Asset.serial_number |
priority (1-3) |
Asset.criticality (A/B/C; no priority is C) |
parent |
Asset.parent |
classstructureid (else assettype) |
Asset.type (via asset_type_map) |
wonum |
WorkOrder.id |
worktype |
WorkOrder.type (CM→Corrective, PM→Preventive, CP→Predictive, EV→Improvement) |
wopriority |
WorkOrder.priority (1→Emergency, 2→High, 3→Medium, 4→Low) |
status |
WorkOrder.status (WAPPR→Created, APPR→Assigned, INPRG→InProgress, COMP→Completed, CLOSE→Closed, CAN→Cancelled) |
assetnum |
WorkOrder.asset_id |
lead (else assignedownergroup) |
WorkOrder.assigned_to |
failurecode |
WorkOrder.failure_mode |
problemcode |
WorkOrder.failure_cause |
reportdate / changedate |
WorkOrder.created_at / WorkOrder.updated_at |
itemnum |
SparePart.sku |
item.description |
SparePart.name |
sum of invbalances.curbal (one row per bin or lot) |
SparePart.stock_quantity (added as decimals, rounded down to whole units; a total below zero is refused) |
minlevel |
SparePart.reorder_point (rounded down; a negative value is refused) |
invcost.avgcost (else lastcost), first row |
SparePart.unit_cost (a negative cost is refused) |
location (the storeroom) |
SparePart.warehouse_location |
pmnum |
MaintenancePlan.id |
assetnum |
MaintenancePlan.asset_id |
frequency × frequnit |
MaintenancePlan.interval (DAYS, WEEKS, MONTHS; YEARS as twelve months; no unit or another unit gives no interval) |
status (ACTIVE) |
MaintenancePlan.active |
siteid |
metadata["siteid"] (assets, work orders, spare parts) |
last segment of href |
metadata["rest_id"] (assets, work orders, spare parts) |
Selected attributes that no field takes stay in metadata. A spare part's reorder flag (yes/no, not a quantity) is one of them when Maximo sends it.
Resilience¶
All HTTP calls route through a shared retry helper with exponential backoff. See SAP PM Connector — Resilience for details.
Known Limitations¶
- Object structure customisation: The connector targets standard Maximo object structures (
mxasset,mxwo,mxinventory,mxpm). Custom object structures require subclassing. - Spare parts by asset:
read_spare_parts(asset_id=...)is refused withConnectorUnsupportedFilterError(aConnectorError) before any request, with or without asku, because the connector reads spare parts frommxinventory, which it does not query by asset. Look parts up byskuinstead. Asset-scoped spare parts for Maximo are not supported yet. - Filter values: asset and work-order numbers, SKUs and statuses are matched exactly through
oslc.where. Maximo documents no escape inside its string literals, and its QBE framework reads wildcards (%,*,?), value lists (,,[ ]) and operators (=,!,<,>,~) inside them as query syntax. A value that contains any of" \ % * ? , [ ] = ! < > ~or a non-printable character, is blank, or is the keywordnullis therefore refused withConnectorErrorbefore any request, including the lookupupdate_work_ordersends before writing. The message names the offending characters, not the value. A number is matched as its digits. - Work-order numbers never reach a URL path:
update_work_ordermatches thewonuminoslc.whereand sends the update to the rest id Maximo returns (see How updates are sent). That rest id is sent as one percent-encoded path segment (/becomes%2F), and one with an empty,.or..part between/or\separators is refused withConnectorError, so a proxy that decodes%2Fcannot resolve it into another resource. - The same
wonumin several sites: there is no site setting yet. An update refuses such awonum;get_work_orderreturns the first match. - Not validated against a live Maximo: the attribute selections (see How reads select attributes) and the response shapes the tests use follow IBM's REST API guide and Maximo's data dictionary. In particular:
- Selections per object structure. A site's object structure can differ from the standard one (a customised
MXASSET, say). IBM does not document what Maximo does with a selected attribute that the object structure lacks. If it refuses the query, the read fails withConnectorErrorand the HTTP status; if it leaves the attribute out instead, the field reads as its default. - Inventory child objects.
read_spare_partsexpectsMXINVENTORYto expose the balances and costs as the child objectsinvbalancesandinvcost, and the item as the related objectitem. If it does not and Maximo leaves them out of its answer rather than refusing the query, every spare part reads with no stock, no cost and no name. - Next-page links. Next pages are requested at the
nextPagelink Maximo returns, which IBM documents as repeating the query. Whether that link keepsoslc.selecthas not been checked: a page that lost it ends the read withConnectorSchemaError, not a shorter list. - Custom frequency units: a PM with no
frequnit, or one that is not DAYS, WEEKS, MONTHS or YEARS (a site-defined synonym, say), gets an empty interval, so it is reported as having no calendar due date. A month counts as 30 days inInterval.total_days, so a yearly PM's interval is 360 days. - Status changes: a status update is sent as the plain
statusattribute. IBM's REST API guide shows status changes only through actions that callchangeStatusand does not say whethermxwoapplies the attribute directly; this has not been checked against a live Maximo. A rejected update raisesConnectorErrorwith the HTTP status. - Pagination: Follows the OSLC
responseInfo.nextPage.hreflink of each page (a bare URL or a relative link is followed too). Each page is requested with the connector's credentials, so a link to another scheme, host or port than the configuredurl, or one with user info in it, raisesConnectorErrorand logsmaximo_next_page_refusedwith the link's scheme and host: Maximo builds its links from its own configured address, so behind a proxy check that it returns the address Machina uses. AnextPagewith no usable URL, a link the server already returned, more than 50,000 rows in one read, or more pages than those rows fill also raiseConnectorErrorinstead of ending the read early or growing without bound.get_assetandget_work_orderread only the first page: they use the first match. Very large result sets may benefit from server-sideoslc.wherefiltering.
API Reference¶
MaximoConnector ¶
MaximoConnector(*, url: str, auth: _AuthUnion | dict[str, Any], lean: bool = True, asset_type_map: dict[str, AssetType] | None = None)
Connector for IBM Maximo Manage.
Provides integration with Maximo's OSLC/JSON REST API for reading and creating maintenance data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
Base URL of the Maximo instance
(e.g. |
required |
auth
|
_AuthUnion | dict[str, Any]
|
Authentication strategy — :class: |
required |
lean
|
bool
|
If |
True
|
asset_type_map
|
dict[str, AssetType] | None
|
Optional mapping from a Maximo classification
key ( |
None
|
Example
from machina.connectors import Maximo
from machina.connectors.cmms import ApiKeyHeaderAuth
from machina.domain.asset import AssetType
connector = Maximo(
url="https://maximo.example.com",
auth=ApiKeyHeaderAuth(header_name="apikey", value="my-key"),
asset_type_map={
"PUMPS": AssetType.ROTATING_EQUIPMENT,
"VESSELS": AssetType.STATIC_EQUIPMENT,
"INSTRUMENTS": AssetType.INSTRUMENT,
},
)
await connector.connect()
assets = await connector.read_assets()
connect
async
¶
Verify credentials against the Maximo API.
Performs a lightweight request to the whoami endpoint.
Raises:
| Type | Description |
|---|---|
ConnectorAuthError
|
If credentials are invalid. |
ConnectorError
|
If the server is unreachable. |
read_assets
async
¶
Return all assets from Maximo (MXASSET object structure).
Raises:
| Type | Description |
|---|---|
ConnectorSchemaError
|
If a record lacks its |
ConnectorError
|
On an HTTP or paging error (see :meth: |
get_asset
async
¶
Look up a single asset by asset number (the first match).
Raises:
| Type | Description |
|---|---|
ConnectorSchemaError
|
If the record lacks its |
ConnectorError
|
If |
read_work_orders
async
¶
Read work orders, optionally filtered by asset or status.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
asset_id
|
str
|
Filter by Maximo asset number. |
''
|
status
|
WorkOrderStatus | str
|
Filter by status — accepts a :class: |
''
|
Raises:
| Type | Description |
|---|---|
ConnectorSchemaError
|
If a record lacks its |
ConnectorError
|
If a filter value is refused by :func: |
get_work_order
async
¶
Look up a single work order by work order number (the first match).
Raises:
| Type | Description |
|---|---|
ConnectorSchemaError
|
If the record lacks its |
ConnectorError
|
If |
create_work_order
async
¶
update_work_order
async
¶
update_work_order(work_order_id: str, *, status: WorkOrderStatus | None = None, assigned_to: str | None = None, description: str | None = None) -> WorkOrder
Update an existing work order in Maximo.
Maximo addresses a record by the URI it returns for it, not by
wonum, so the order is first looked up by wonum
(:meth:_work_order_rest_id), the update is sent to its URI through
:meth:_patch_resource, and the order is read back from the same URI.
Only non-None fields are sent. close_work_order and
cancel_work_order go through this method.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
work_order_id
|
str
|
Maximo work order number ( |
required |
status
|
WorkOrderStatus | None
|
New status (reverse-mapped to Maximo code). |
None
|
assigned_to
|
str | None
|
New lead person. |
None
|
description
|
str | None
|
New work order description. |
None
|
Returns:
| Type | Description |
|---|---|
WorkOrder
|
The updated work order, read back from its URI. |
Raises:
| Type | Description |
|---|---|
ConnectorError
|
If no field is given; if the lookup refuses the
|
ConnectorAuthError
|
If Maximo answers the lookup or the update with 401. |
HTTPError
|
A transport error from the lookup (after its retries) or from the update, which is not retried. |
close_work_order
async
¶
Transition a work order to CLOSED status.
cancel_work_order
async
¶
Transition a work order to CANCELLED status.
read_spare_parts
async
¶
Read inventory items (spare parts) from Maximo.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
asset_id
|
str
|
Not supported yet: the connector reads spare parts from
|
''
|
sku
|
str
|
Optional Maximo |
''
|
Raises:
| Type | Description |
|---|---|
ConnectorUnsupportedFilterError
|
If |
ConnectorSchemaError
|
If a record lacks its |
ConnectorError
|
If a filter is neither |
read_maintenance_plans
async
¶
Read preventive-maintenance plans (PMs) from Maximo.
Raises:
| Type | Description |
|---|---|
ConnectorSchemaError
|
If a record lacks its |
ConnectorError
|
On an HTTP or paging error (see :meth: |
read_maintenance_history
async
¶
Return completed/closed work orders for an asset.
Raises:
| Type | Description |
|---|---|
ConnectorSchemaError
|
If a record lacks its |
ConnectorError
|
If |