cremalink 0.1.0b5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. cremalink/__init__.py +33 -0
  2. cremalink/clients/__init__.py +10 -0
  3. cremalink/clients/cloud.py +130 -0
  4. cremalink/core/__init__.py +6 -0
  5. cremalink/core/binary.py +102 -0
  6. cremalink/crypto/__init__.py +142 -0
  7. cremalink/devices/AY008ESP1.json +114 -0
  8. cremalink/devices/__init__.py +116 -0
  9. cremalink/domain/__init__.py +11 -0
  10. cremalink/domain/device.py +245 -0
  11. cremalink/domain/factory.py +98 -0
  12. cremalink/local_server.py +76 -0
  13. cremalink/local_server_app/__init__.py +20 -0
  14. cremalink/local_server_app/api.py +272 -0
  15. cremalink/local_server_app/config.py +64 -0
  16. cremalink/local_server_app/device_adapter.py +96 -0
  17. cremalink/local_server_app/jobs.py +104 -0
  18. cremalink/local_server_app/logging.py +116 -0
  19. cremalink/local_server_app/models.py +76 -0
  20. cremalink/local_server_app/protocol.py +135 -0
  21. cremalink/local_server_app/state.py +358 -0
  22. cremalink/parsing/__init__.py +7 -0
  23. cremalink/parsing/monitor/__init__.py +22 -0
  24. cremalink/parsing/monitor/decode.py +79 -0
  25. cremalink/parsing/monitor/extractors.py +69 -0
  26. cremalink/parsing/monitor/frame.py +132 -0
  27. cremalink/parsing/monitor/model.py +42 -0
  28. cremalink/parsing/monitor/profile.py +144 -0
  29. cremalink/parsing/monitor/view.py +196 -0
  30. cremalink/parsing/properties/__init__.py +9 -0
  31. cremalink/parsing/properties/decode.py +53 -0
  32. cremalink/resources/__init__.py +10 -0
  33. cremalink/resources/api_config.json +14 -0
  34. cremalink/resources/api_config.py +30 -0
  35. cremalink/resources/lang.json +223 -0
  36. cremalink/transports/__init__.py +7 -0
  37. cremalink/transports/base.py +94 -0
  38. cremalink/transports/cloud/__init__.py +9 -0
  39. cremalink/transports/cloud/transport.py +166 -0
  40. cremalink/transports/local/__init__.py +9 -0
  41. cremalink/transports/local/transport.py +164 -0
  42. cremalink-0.1.0b5.dist-info/METADATA +138 -0
  43. cremalink-0.1.0b5.dist-info/RECORD +47 -0
  44. cremalink-0.1.0b5.dist-info/WHEEL +5 -0
  45. cremalink-0.1.0b5.dist-info/entry_points.txt +2 -0
  46. cremalink-0.1.0b5.dist-info/licenses/LICENSE +661 -0
  47. cremalink-0.1.0b5.dist-info/top_level.txt +1 -0
cremalink/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """
2
+ Cremalink: A Python library for interacting with De'Longhi coffee machines.
3
+
4
+ This top-level package exposes the primary user-facing classes and functions
5
+ for easy access, including the main `Client`, the `Device` model, and factory
6
+ functions for creating device instances.
7
+ """
8
+ from cremalink.clients.cloud import Client
9
+ from cremalink.domain import Device, create_cloud_device, create_local_device
10
+ from cremalink.local_server_app import create_app, ServerSettings
11
+ from cremalink.local_server import LocalServer
12
+ from cremalink.devices import device_map
13
+ from importlib.metadata import PackageNotFoundError, version
14
+
15
+ __all__ = [
16
+ "Client",
17
+ "Device",
18
+ "create_local_device",
19
+ "create_cloud_device",
20
+ "LocalServer",
21
+ "create_app",
22
+ "ServerSettings",
23
+ "device_map",
24
+ ]
25
+
26
+ try:
27
+ __name__ = "cremalink"
28
+ # Retrieve the package version from installed metadata.
29
+ __version__ = version(__name__)
30
+ except PackageNotFoundError:
31
+ # If the package is not installed (e.g., running from source),
32
+ # fall back to a default version.
33
+ __version__ = "0.0.0"
@@ -0,0 +1,10 @@
1
+ """
2
+ This package provides high-level client interfaces for interacting with
3
+ the cremalink system.
4
+
5
+ The `Client` class from the `cloud` module is exposed as the primary
6
+ entry point for this package.
7
+ """
8
+ from cremalink.clients.cloud import Client
9
+
10
+ __all__ = ["Client"]
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+
6
+ import requests
7
+
8
+ from cremalink.domain import create_cloud_device
9
+ from cremalink.resources import load_api_config
10
+
11
+ API_USER_AGENT = "datatransport/3.1.2 android/"
12
+ TOKEN_USER_AGENT = "DeLonghiComfort/3 CFNetwork/1568.300.101 Darwin/24.2.0"
13
+
14
+
15
+ class Client:
16
+ """
17
+ Client for interacting with the Ayla IoT cloud platform.
18
+ Manages authentication (access and refresh tokens) and device discovery.
19
+ """
20
+
21
+ def __init__(self, token_path: str):
22
+ # Ensure the token_path points to a JSON file.
23
+ if not token_path.endswith(".json"):
24
+ raise ValueError("token_path must point to a .json file")
25
+
26
+ # Load API configuration from resources.
27
+ self.api_conf = load_api_config()
28
+ self.gigya_api = self.api_conf.get("GIGYA")
29
+ self.ayla_api = self.api_conf.get("AYLA")
30
+
31
+ self.token_path = token_path
32
+ # Retrieve or refresh the access token upon initialization.
33
+ self.access_token = self.__get_access_token()
34
+ # Fetch the list of devices associated with the account.
35
+ self.devices = requests.get(
36
+ url=f"{self.ayla_api.get('API_URL')}/devices.json",
37
+ headers={
38
+ "User-Agent": API_USER_AGENT,
39
+ "Authorization": f"auth_token {self.access_token}",
40
+ "Accept": "application/json",
41
+ },
42
+ ).json()
43
+
44
+ def get_devices(self):
45
+ """
46
+ Retrieves a list of Device Serial Numbers (DSNs) for all registered devices.
47
+
48
+ Returns:
49
+ list[str]: A list of DSNs.
50
+ """
51
+ devices: list[str] = []
52
+ for device in self.devices:
53
+ devices.append(device["device"]["dsn"])
54
+ return devices
55
+
56
+ def get_device(self, dsn: str, device_map_path: dict | None = None):
57
+ """
58
+ Retrieves a specific cloud device by its DSN.
59
+
60
+ Args:
61
+ dsn (str): The Device Serial Number of the desired device.
62
+ device_map_path (dict | None): Optional mapping for device properties.
63
+
64
+ Returns:
65
+ CloudDevice | None: An instance of CloudDevice if found, otherwise None.
66
+ """
67
+ for device_dsn in self.get_devices():
68
+ if device_dsn == dsn:
69
+ return create_cloud_device(device_dsn, self.access_token, device_map_path)
70
+ return None
71
+
72
+ def __get_access_token(self):
73
+ """
74
+ Retrieves a valid access token, refreshing it if necessary using the refresh token.
75
+ """
76
+ refresh_token = self.__get_refresh_token()
77
+ # If no refresh token is found, prompt the user to provide one.
78
+ if not refresh_token or refresh_token == "":
79
+ self.__set_refresh_token("")
80
+ raise ValueError(f"No refresh token found. Open {self.token_path} and add a valid refresh token.")
81
+ response = requests.post(
82
+ url=f"{self.ayla_api.get('OAUTH_URL')}/users/refresh_token.json",
83
+ headers={
84
+ "User-Agent": TOKEN_USER_AGENT,
85
+ "Content-Type": "application/json",
86
+ },
87
+ json={"user": {"refresh_token": refresh_token}},
88
+ )
89
+ if response.status_code == 200:
90
+ # If successful, extract new access and refresh tokens.
91
+ data = response.json()
92
+ new_access_token = data["access_token"]
93
+ new_refresh_token = data["refresh_token"]
94
+ # Update the stored refresh token.
95
+ self.__set_refresh_token(new_refresh_token)
96
+ return new_access_token
97
+ else:
98
+ # Raise an error if access token retrieval fails.
99
+ raise ValueError(f"Failed to get access token: {response.status_code} {response.text}")
100
+
101
+ def __get_refresh_token(self):
102
+ """
103
+ Reads the refresh token from the token file.
104
+
105
+ Returns:
106
+ str | None: The refresh token if found, otherwise None.
107
+ """
108
+ if os.path.exists(self.token_path):
109
+ with open(self.token_path, "r") as f:
110
+ data = f.read()
111
+ f.close()
112
+ if data:
113
+ token_data = json.loads(data)
114
+ return token_data.get("refresh_token", None)
115
+ return None
116
+
117
+ def __set_refresh_token(self, refresh_token: str):
118
+ """
119
+ Writes the provided refresh token to the token file.
120
+
121
+ Args:
122
+ refresh_token (str): The new refresh token to store.
123
+ """
124
+ with open(self.token_path, "w+") as f:
125
+ # Read existing data to preserve other potential keys.
126
+ data = f.read()
127
+ token_data = json.loads(data) if data else {}
128
+ token_data["refresh_token"] = refresh_token
129
+ f.write(json.dumps(token_data, indent=2))
130
+ f.close()
@@ -0,0 +1,6 @@
1
+ """
2
+ This package contains core utilities and base functionalities that are used
3
+ across the cremalink library.
4
+ """
5
+
6
+ __all__ = []
@@ -0,0 +1,102 @@
1
+ """
2
+ This module provides binary utility functions for handling data conversions,
3
+ checksum calculations, and bitwise operations, often required for communication
4
+ with hardware devices.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ from typing import Iterable
10
+
11
+ # Polynomial for CRC-16 CCITT calculation.
12
+ CRC16_POLY = 0x1021
13
+
14
+
15
+ def crc16_ccitt(data: bytes) -> bytes:
16
+ """
17
+ Calculates the CRC-16 CCITT checksum for the given data.
18
+
19
+ This implementation uses a specific initial value (0x1D0F) and polynomial (0x1021).
20
+
21
+ Args:
22
+ data: The input bytes to checksum.
23
+
24
+ Returns:
25
+ A 2-byte sequence representing the calculated CRC in big-endian format.
26
+ """
27
+ crc = 0x1D0F # Initial CRC value.
28
+ for byte in data:
29
+ crc ^= byte << 8
30
+ for _ in range(8):
31
+ if crc & 0x8000:
32
+ crc = (crc << 1) ^ CRC16_POLY
33
+ else:
34
+ crc <<= 1
35
+ return (crc & 0xFFFF).to_bytes(2, byteorder="big")
36
+
37
+
38
+ def b64_to_cmd_hex(b64_data: str) -> str:
39
+ """
40
+ Decodes a base64 string, extracts a command frame, and returns it as a hex string.
41
+
42
+ The function cleans the input base64 string, decodes it, and then uses the
43
+ second byte of the decoded data as a length specifier to extract the
44
+ relevant command frame.
45
+
46
+ Args:
47
+ b64_data: The base64 encoded data string.
48
+
49
+ Returns:
50
+ The extracted command frame as a hexadecimal string.
51
+ """
52
+ # Remove whitespace and add padding if necessary.
53
+ cleaned = "".join(b64_data.split())
54
+ cleaned += "=" * (-len(cleaned) % 4)
55
+
56
+ # Decode the base64 string.
57
+ raw = base64.b64decode(cleaned)
58
+
59
+ # The second byte (index 1) is the length of the command frame.
60
+ length = raw[1]
61
+ cmd_frame = raw[: length + 1]
62
+
63
+ # Return the command frame as a hex string.
64
+ return cmd_frame.hex()
65
+
66
+
67
+ def get_bit(byte_value: int, bit_index: int) -> bool:
68
+ """
69
+ Gets the value of a specific bit in a byte.
70
+
71
+ Args:
72
+ byte_value: The integer representation of the byte.
73
+ bit_index: The index of the bit to retrieve (0-7).
74
+
75
+ Returns:
76
+ True if the bit is 1, False if it is 0.
77
+
78
+ Raises:
79
+ ValueError: If bit_index is not between 0 and 7.
80
+ """
81
+ if not 0 <= bit_index <= 7:
82
+ raise ValueError("bit_index must be between 0 and 7")
83
+ return bool(byte_value & (1 << bit_index))
84
+
85
+
86
+ def safe_byte_at(data: Iterable[int] | bytes, index: int) -> int | None:
87
+ """
88
+ Safely retrieves a byte from a sequence or iterable at a given index.
89
+
90
+ Args:
91
+ data: The data to access, can be bytes or an iterable of integers.
92
+ index: The index of the byte to retrieve.
93
+
94
+ Returns:
95
+ The byte as an integer if the index is valid, otherwise None.
96
+ """
97
+ try:
98
+ # Convert to list to handle various iterable types and access by index.
99
+ return list(data)[index]
100
+ except (IndexError, TypeError):
101
+ # Return None if index is out of bounds or type is not subscriptable.
102
+ return None
@@ -0,0 +1,142 @@
1
+ """
2
+ This module provides a set of cryptographic helper functions for handling
3
+ encryption, decryption, and hashing, primarily using AES and HMAC.
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ import hmac
9
+
10
+ from Crypto.Cipher import AES
11
+
12
+
13
+ def hmac_for_key_and_data(key: bytes, data: bytes) -> bytes:
14
+ """
15
+ Generates an HMAC-SHA256 digest for the given key and data.
16
+
17
+ Args:
18
+ key: The secret key for the HMAC operation.
19
+ data: The message data to hash.
20
+
21
+ Returns:
22
+ The raw HMAC-SHA256 digest as bytes.
23
+ """
24
+ mac_hash = hmac.new(key, data, hashlib.sha256)
25
+ return mac_hash.digest()
26
+
27
+
28
+ def pad_zero(data: bytes, block_size: int = 16) -> bytes:
29
+ """
30
+ Pads data with zero bytes to a multiple of the specified block size.
31
+
32
+ Note: This padding scheme is not reversible if the original data can
33
+ end with zero bytes.
34
+
35
+ Args:
36
+ data: The bytes to pad.
37
+ block_size: The block size to align to. Defaults to 16.
38
+
39
+ Returns:
40
+ The zero-padded data.
41
+ """
42
+ padding_length = block_size - len(data) % block_size
43
+ return data + (padding_length * b"\x00")
44
+
45
+
46
+ def unpad_zero(data: bytes) -> bytes:
47
+ """
48
+ Removes trailing zero-byte padding from data.
49
+
50
+ This works by finding the first null byte and truncating the rest.
51
+
52
+ Args:
53
+ data: The padded bytes.
54
+
55
+ Returns:
56
+ The unpadded data.
57
+ """
58
+ # Finds the first occurrence of a null byte and slices the data up to that point.
59
+ return data.rstrip(b"\x00")
60
+
61
+
62
+ def extract_bits(raw: bytes, start: int, end: int) -> bytearray:
63
+ """
64
+ Extracts a slice from the hexadecimal representation of raw bytes.
65
+
66
+ Note: The name is a misnomer; it operates on the hex string, not raw bits.
67
+
68
+ Args:
69
+ raw: The input bytes.
70
+ start: The starting index in the hex string representation.
71
+ end: The ending index in the hex string representation.
72
+
73
+ Returns:
74
+ A bytearray corresponding to the specified hex slice.
75
+ """
76
+ strhex = raw.hex()
77
+ return bytearray.fromhex(strhex[start:end])
78
+
79
+
80
+ def aes_encrypt(message: str, key: bytes, iv: bytes) -> str:
81
+ """
82
+ Encrypts a string using AES-128 in CBC mode with zero-padding.
83
+
84
+ Args:
85
+ message: The string message to encrypt.
86
+ key: The 16-byte encryption key.
87
+ iv: The 16-byte initialization vector (IV).
88
+
89
+ Returns:
90
+ A base64-encoded string of the encrypted data.
91
+ """
92
+ raw = pad_zero(message.encode())
93
+ cipher = AES.new(key, AES.MODE_CBC, iv)
94
+ enc = cipher.encrypt(raw)
95
+ return base64.b64encode(enc).decode("utf-8")
96
+
97
+
98
+ def aes_decrypt(enc: str, key: bytes, iv: bytes) -> bytes:
99
+ """
100
+ Decrypts a base64-encoded string using AES-128 in CBC mode.
101
+
102
+ Args:
103
+ enc: The base64-encoded encrypted string.
104
+ key: The 16-byte decryption key.
105
+ iv: The 16-byte initialization vector (IV).
106
+
107
+ Returns:
108
+ The decrypted data as bytes, after removing zero-padding.
109
+ """
110
+ decoded = base64.b64decode(enc)
111
+ cipher = AES.new(key, AES.MODE_CBC, iv)
112
+ dec = cipher.decrypt(decoded)
113
+ return unpad_zero(dec)
114
+
115
+
116
+ def rotate_iv_from_ciphertext(enc: str) -> bytes:
117
+ """
118
+ Extracts the last 16 bytes from a ciphertext to be used as the next IV.
119
+
120
+ This is a common technique in some protocols to chain IVs.
121
+
122
+ Args:
123
+ enc: The base64-encoded ciphertext.
124
+
125
+ Returns:
126
+ The last 16 bytes of the raw ciphertext.
127
+ """
128
+ decoded_hex = base64.b64decode(enc).hex()
129
+ # The last 16 bytes are the last 32 characters of the hex string.
130
+ return bytearray.fromhex(decoded_hex[-32:])
131
+
132
+
133
+ # Specifies the public API of this module.
134
+ __all__ = [
135
+ "aes_decrypt",
136
+ "aes_encrypt",
137
+ "extract_bits",
138
+ "hmac_for_key_and_data",
139
+ "pad_zero",
140
+ "rotate_iv_from_ciphertext",
141
+ "unpad_zero",
142
+ ]
@@ -0,0 +1,114 @@
1
+ {
2
+ "device_type": "AY008ESP1",
3
+ "command_map": {
4
+ "americano": {
5
+ "command": "0d1483f006010100280f006e1b010203040006ecfe",
6
+ "name": "Americano"
7
+ },
8
+ "coffee": {
9
+ "command": "0d1183f002011b040100f002030400060ab7",
10
+ "name": "Coffee"
11
+ },
12
+ "doppio_plus": {
13
+ "command": "0d0f83f005020100601b00040006138e",
14
+ "name": "Doppio Plus"
15
+ },
16
+ "double_espresso": {
17
+ "command": "0d0883f00401062342",
18
+ "name": "Double Espresso"
19
+ },
20
+ "espresso": {
21
+ "command": "0d1183f0010101007e1b030202040006afe2",
22
+ "name": "Espresso"
23
+ },
24
+ "espresso_soul": {
25
+ "command": "0d0d83f0c80101003c1b04064270",
26
+ "name": "Espresso Soul"
27
+ },
28
+ "hot_water": {
29
+ "command": "0d0d83f010010f00fa1b01068124",
30
+ "name": "Hot Water"
31
+ },
32
+ "long_coffee": {
33
+ "command": "0d1183f003010100a01b010203040006fc08",
34
+ "name": "Long Coffee"
35
+ },
36
+ "refresh": {
37
+ "command": "0d07840f03025640",
38
+ "name": "Refresh Status"
39
+ },
40
+ "standby": {
41
+ "command": "0d07840f01010041",
42
+ "name": "Turn Off"
43
+ },
44
+ "stop": {
45
+ "command": "0d0d83f010020f00fa1b010659a6",
46
+ "name": "Stop"
47
+ },
48
+ "wakeup": {
49
+ "command": "0d07840f02015512",
50
+ "name": "Turn On"
51
+ }
52
+ },
53
+ "property_map": {
54
+ "monitor": "d302_monitor"
55
+ },
56
+ "monitor_profile": {
57
+ "enums": {
58
+ "accessory": {
59
+ "0": "none",
60
+ "1": "hot_water_spout",
61
+ "2": "latte_crema_hot",
62
+ "3": "chocolate",
63
+ "4": "latte_crema_hot_clean",
64
+ "6": "latte_crema_cool",
65
+ "7": "latte_crema_cool_clean"
66
+ },
67
+ "status": {
68
+ "0": "in_standby",
69
+ "1": "waking_up",
70
+ "2": "going_to_sleep",
71
+ "4": "descaling",
72
+ "5": "preparing_steam",
73
+ "7": "ready",
74
+ "8": "rinsing",
75
+ "10": "preparing_milk",
76
+ "11": "dispensing_hot_water",
77
+ "12": "cleaning_milk",
78
+ "16": "preparing_chocolate",
79
+ "17": "preparing_milk_alt",
80
+ "29": "unknown_29"
81
+ },
82
+ "action": {}
83
+ },
84
+ "flags": {
85
+ "is_watertank_open": {
86
+ "source": "alarms",
87
+ "byte": 1,
88
+ "bit": 5
89
+ },
90
+ "is_watertank_empty": {
91
+ "source": "alarms",
92
+ "byte": 0,
93
+ "bit": 0
94
+ },
95
+ "is_waste_container_missing": {
96
+ "source": "switches",
97
+ "byte": 0,
98
+ "bit": 3
99
+ }
100
+ },
101
+ "predicates": {
102
+ "is_busy": {
103
+ "kind": "not_equals",
104
+ "source": "action",
105
+ "value": 0
106
+ },
107
+ "is_idle": {
108
+ "kind": "equals",
109
+ "source": "action",
110
+ "value": 0
111
+ }
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,116 @@
1
+ """
2
+ This module handles the discovery and loading of device definition files,
3
+ referred to as "device maps."
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import pathlib
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any, List
12
+ from importlib import resources
13
+
14
+
15
+ class DeviceMapNotFoundError(FileNotFoundError):
16
+ """Custom exception raised when a specific device map cannot be found."""
17
+ pass
18
+
19
+
20
+ def _normalize_model_id(model_id: str) -> str:
21
+ """
22
+ Cleans and validates the model ID string.
23
+
24
+ Args:
25
+ model_id: The raw model ID.
26
+
27
+ Returns:
28
+ A normalized model ID string.
29
+
30
+ Raises:
31
+ ValueError: If the model_id is empty or just whitespace.
32
+ """
33
+ if not model_id or not model_id.strip():
34
+ raise ValueError("device_map(model_id) requires a non-empty model_id.")
35
+ model_id = model_id.strip()
36
+ # Remove .json extension if present, case-insensitively.
37
+ if model_id.lower().endswith(".json"):
38
+ model_id = model_id[:-5]
39
+ return model_id
40
+
41
+
42
+ def get_device_maps() -> List[str]:
43
+ """
44
+ Lists all available device maps by scanning the package data.
45
+
46
+ Returns:
47
+ A sorted list of unique model IDs (without the .json extension).
48
+ """
49
+ # Access the 'cremalink.devices' package as a resource container.
50
+ base = resources.files("cremalink.devices")
51
+ models: List[str] = []
52
+ for entry in base.iterdir():
53
+ # Find all .json files in the package.
54
+ if entry.is_file() and entry.name.lower().endswith(".json"):
55
+ models.append(Path(entry.name).stem)
56
+ # Return a sorted list of unique model names.
57
+ return sorted(set(models))
58
+
59
+
60
+ def device_map(model_id: str) -> str:
61
+ """
62
+ Finds the absolute path to a device map file for a given model ID.
63
+
64
+ This function handles packaged resources, extracting them to a temporary
65
+ directory if they are not directly accessible on the filesystem.
66
+
67
+ Args:
68
+ model_id: The model ID of the device.
69
+
70
+ Returns:
71
+ The absolute path to the device map JSON file as a string.
72
+
73
+ Raises:
74
+ DeviceMapNotFoundError: If the map for the given model ID doesn't exist.
75
+ """
76
+ model_id = _normalize_model_id(model_id)
77
+ filename = f"{model_id}.json"
78
+
79
+ base = resources.files("cremalink.devices")
80
+ res: pathlib.Path = base.joinpath(filename)
81
+
82
+ if not res.exists():
83
+ available = get_device_maps()
84
+ raise DeviceMapNotFoundError(
85
+ f"Device map '{model_id}' not found. Available: {available}"
86
+ )
87
+
88
+ try:
89
+ # Efficiently get the file path if the resource is on the filesystem.
90
+ with resources.as_file(res) as p:
91
+ return str(Path(p))
92
+ except Exception:
93
+ # Fallback for zipped packages: copy the file to a temp location.
94
+ cache_dir = Path(tempfile.gettempdir()) / "cremalink_device_maps"
95
+ cache_dir.mkdir(parents=True, exist_ok=True)
96
+ target = cache_dir / filename
97
+
98
+ # Write the file to the temp cache and return its path.
99
+ target.write_bytes(res.read_bytes())
100
+ return str(target)
101
+
102
+
103
+ def load_device_map(model_id: str) -> dict[str, Any]:
104
+ """
105
+ Loads a device map from its JSON file into a dictionary.
106
+
107
+ Args:
108
+ model_id: The model ID of the device to load.
109
+
110
+ Returns:
111
+ A dictionary containing the device map data, or an empty dict on failure.
112
+ """
113
+ path = device_map(model_id)
114
+ with open(path, "r", encoding="utf-8") as f:
115
+ data = json.load(f)
116
+ return data if isinstance(data, dict) else {}
@@ -0,0 +1,11 @@
1
+ """
2
+ This package defines the core domain models for the cremalink library,
3
+ representing the main entities like the coffee machine itself.
4
+
5
+ It exposes the primary `Device` class and factory functions for creating
6
+ device instances, abstracting away the underlying implementation details.
7
+ """
8
+ from cremalink.domain.device import Device
9
+ from cremalink.domain.factory import create_cloud_device, create_local_device
10
+
11
+ __all__ = ["Device", "create_cloud_device", "create_local_device"]