Initial commit

This commit is contained in:
Mustafa KURU
2026-05-03 23:47:54 +03:00
commit 1dac1749bf
74 changed files with 6697 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
* text=auto
*.py text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.json text eol=lf
Dockerfile* text eol=lf
*.sh text eol=lf
addon/rootfs/etc/services.d/**/run text eol=lf
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+66
View File
@@ -0,0 +1,66 @@
name: CI
on:
push:
tags-ignore:
- "v*"
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Validate JSON
run: |
python -m json.tool hacs.json > /dev/null
python -m json.tool repository.json > /dev/null
python -m json.tool custom_components/elegoo_spaghetti_detection/manifest.json > /dev/null
python -m json.tool custom_components/elegoo_spaghetti_detection/translations/en.json > /dev/null
- name: Validate Python syntax
run: python -m compileall custom_components/elegoo_spaghetti_detection addon/rootfs/app
- name: Validate YAML
run: |
python -m pip install pyyaml
python - <<'PY'
from pathlib import Path
import yaml
class Loader(yaml.SafeLoader):
pass
def unknown_constructor(loader, tag_suffix, node):
if isinstance(node, yaml.MappingNode):
return loader.construct_mapping(node)
if isinstance(node, yaml.SequenceNode):
return loader.construct_sequence(node)
return loader.construct_scalar(node)
Loader.add_multi_constructor("!", unknown_constructor)
paths = [
Path("docker-compose.yaml"),
Path("addon/config.yaml"),
Path("custom_components/elegoo_spaghetti_detection/services.yaml"),
Path(".github/workflows/ci.yaml"),
Path(".github/workflows/hassfest.yaml"),
Path(".github/workflows/validate.yaml"),
Path(".github/dependabot.yml"),
*Path("examples").glob("*.yaml"),
]
for path in sorted(paths):
with path.open("r", encoding="utf-8") as handle:
yaml.load(handle, Loader=Loader)
PY
+20
View File
@@ -0,0 +1,20 @@
name: Validate with hassfest
on:
push:
tags-ignore:
- "v*"
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: home-assistant/actions/hassfest@master
+23
View File
@@ -0,0 +1,23 @@
name: Validate
on:
push:
tags-ignore:
- "v*"
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
validate-hacs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: HACS validation
uses: hacs/action@main
with:
category: integration
+162
View File
@@ -0,0 +1,162 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
.vscode/
+21
View File
@@ -0,0 +1,21 @@
# Contributing
Work locally, keep changes focused, and test in Home Assistant before opening a
pull request.
- Use clear commit messages.
- Do not commit Home Assistant tokens, camera proxy tokens, SSH keys, or local
deployment notes.
- Keep the integration domain as `elegoo_spaghetti_detection`.
- Validate JSON, YAML, Python syntax, HACS, and Hassfest before opening a pull
request.
## Local Validation
```bash
python -m json.tool hacs.json > /dev/null
python -m json.tool custom_components/elegoo_spaghetti_detection/manifest.json > /dev/null
python -m compileall custom_components/elegoo_spaghetti_detection addon/rootfs/app
```
The GitHub workflows run the full repository checks.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+156
View File
@@ -0,0 +1,156 @@
# Elegoo Spaghetti Detection
Home Assistant spaghetti/failure detection for Elegoo FDM printers. It is
tested with Elegoo Centauri Carbon 2 through
[`danielcherubini/elegoo-homeassistant`](https://github.com/danielcherubini/elegoo-homeassistant),
but the detector can use any Home Assistant camera entity.
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hepter&repository=ha-elegoo-spaghetti-detection&category=integration)
This project started as an Elegoo-focused adaptation of
[`nberktumer/ha-bambu-lab-p1-spaghetti-detection`](https://github.com/nberktumer/ha-bambu-lab-p1-spaghetti-detection).
The original project provided the Obico ML workflow and Home Assistant
integration foundation.
This repository is not affiliated with Elegoo, Home Assistant, HACS, Obico, or
the original upstream author.
## Scope
The integration detects possible print failures and exposes Home Assistant
entities/events. It does not directly control the printer.
Detection flow:
```text
camera snapshot -> ML server -> confidence/result sensors -> Home Assistant events
```
Printer-specific actions such as pause, resume, stop, and notifications belong
in user automations. Ready-to-edit examples are included.
## Features
- Works with Home Assistant camera entities, including Elegoo chamber cameras.
- Uses an Obico/TSD FDM failure model running in a local Docker/HA add-on server.
- Validates ML server health and camera image reachability during setup.
- Optional print status sensor gates scheduled detection to active print states.
- Elegoo `print_status` sensors are guarded by the companion `current_status`
sensor when it exists, avoiding scheduled checks during homing/idle states
where `print_status` can remain `printing`.
- Optional chamber light control can leave the light alone, turn it on and keep
it on, or temporarily turn it on and restore the previous state after each
snapshot.
- Manual `Test Spaghetti Detection` button.
- Confidence, raw score, detection count, status, last run, next run, and last
error sensors.
- `binary_sensor.<prefix>_spaghetti_detected`.
- Events for every result and for detected failures.
- ML server web dashboard, JSON status, recent request logs, and image debug
endpoint.
- CPU-first ML startup by default to avoid CUDA timeout failures on systems
without a working GPU runtime.
## Documentation
- [Installation](docs/installation.md)
- [Configuration](docs/configuration.md)
- [Automation examples](docs/automations.md)
- [Dashboard examples](docs/dashboard.md)
- [ML server and logs](docs/ml-server.md)
- [Troubleshooting](docs/troubleshooting.md)
- [HACS publishing notes](docs/HACS_PUBLISHING.md)
## Screenshots
Integration setup:
![Elegoo Spaghetti Detection setup form](docs/images/config-flow-add-hub.png)
Enhanced dashboard in idle state:
![Enhanced dashboard idle state](docs/images/dashboard-hacs-waiting-for-print.png)
Enhanced dashboard after a detected failure:
![Enhanced dashboard detected failure](docs/images/dashboard-hacs-detected.png)
Camera frame with an obvious spaghetti failure:
![Camera frame with spaghetti failure](docs/images/camera-spaghetti-failure.png)
## Quick Start
1. Run the ML server. See [ML server and logs](docs/ml-server.md).
2. Install the custom integration through HACS or manually. See
[Installation](docs/installation.md).
3. Add `Elegoo Spaghetti Detection` from Home Assistant integrations.
4. Select the camera and optional print status sensor. See
[Configuration](docs/configuration.md).
5. Press `Test Spaghetti Detection`.
6. Add one of the [automation examples](docs/automations.md).
7. Add one of the [dashboard examples](docs/dashboard.md).
## Typical Elegoo CC2 Entities
Your entity IDs depend on the printer/device name in Home Assistant. With a
default-ish Elegoo Centauri Carbon 2 setup they often look like:
```text
camera.elegoo_centauri_carbon2_chamber_camera
sensor.elegoo_centauri_carbon2_print_status
light.elegoo_centauri_carbon2_chamber_light
button.elegoo_centauri_carbon2_pause_print
button.elegoo_centauri_carbon2_resume_print
button.elegoo_centauri_carbon2_stop_print
```
The integration setup uses only camera, optional print status, and optional
light. Pause/stop/resume are shown only in automation examples.
## Events
Every detection result fires:
```text
elegoo_spaghetti_detection_result
```
Detected failures fire:
```text
elegoo_spaghetti_detection_detected
```
When a print status sensor is configured, scheduled detected events are emitted
once per active print window. If the printer leaves the configured active states
and later returns to an active state, a new detected failure can emit one new
event.
Use this event for notification, pause, and stop automations.
`elegoo_spaghetti_detection_result` still fires for every completed check. Use
that event for logging, dashboards, or advanced automations only; notification
automations based on the result event can repeat every detection interval.
Event data includes:
```text
config_entry
detector
name
camera
manual
printer_state
confidence
raw_score
detected
detections
image_url
last_error
last_run
next_run
status
```
Use these fields in notifications and advanced automations.
+3
View File
@@ -0,0 +1,3 @@
model/*.onnx
model/*.darknet
+1
View File
@@ -0,0 +1 @@
*.weights filter=lfs diff=lfs merge=lfs -text
+2
View File
@@ -0,0 +1,2 @@
model/*.onnx
model/*.darknet
+37
View File
@@ -0,0 +1,37 @@
FROM ghcr.io/home-assistant/amd64-base-debian:bookworm as darknet_builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt update && apt install -y ca-certificates build-essential gcc g++ cmake git
WORKDIR /
# Lock darknet version for reproducibility.
RUN git clone https://github.com/AlexeyAB/darknet && cd darknet && git checkout 59c86222c5387bffd9108a21885f80e980ece234
RUN cd darknet \
&& sed -i 's/GPU=1/GPU=0/' Makefile \
&& sed -i 's/CUDNN=1/CUDNN=0/' Makefile \
&& sed -i 's/CUDNN_HALF=1/CUDNN_HALF=0/' Makefile \
&& sed -i 's/LIBSO=0/LIBSO=1/' Makefile \
&& make -j 4 && mv libdarknet.so libdarknet_cpu.so
FROM ghcr.io/home-assistant/amd64-base-debian:bookworm
RUN apt update && apt install --no-install-recommends -y ca-certificates python3-pip wget python3 python3-venv
COPY rootfs /
COPY --from=darknet_builder /darknet /darknet
WORKDIR /app
RUN python3 -m venv venv
ENV VIRTUAL_ENV=/app/venv
ENV PATH=/app/venv/bin:$PATH
RUN pip3 install --upgrade pip && \
pip3 install opencv_python_headless && \
pip3 install -r requirements.txt
RUN echo 'Downloading the latest failure detection AI model in Darknet format...' && \
wget -O model/model-weights.darknet $(cat model/model-weights.darknet.url | tr -d '\r') && \
echo 'Downloading the latest failure detection AI model in ONNX format...' && \
wget -O model/model-weights.onnx $(cat model/model-weights.onnx.url | tr -d '\r')
RUN chmod +x /etc/services.d/ha-elegoo-spaghetti-detection/run
+44
View File
@@ -0,0 +1,44 @@
FROM ghcr.io/home-assistant/amd64-base-debian:bookworm as darknet_builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt update && apt install -y ca-certificates build-essential gcc g++ cmake git
WORKDIR /
# Lock darknet version for reproducibility
RUN git clone https://github.com/AlexeyAB/darknet && cd darknet && git checkout 59c86222c5387bffd9108a21885f80e980ece234
# compile CPU version
RUN cd darknet \
&& sed -i 's/GPU=1/GPU=0/' Makefile \
&& sed -i 's/CUDNN=1/CUDNN=0/' Makefile \
&& sed -i 's/CUDNN_HALF=1/CUDNN_HALF=0/' Makefile \
&& sed -i 's/LIBSO=0/LIBSO=1/' Makefile \
&& make -j 4 && mv libdarknet.so libdarknet_cpu.so
# -----------------------------------------------------------------------------
FROM ghcr.io/home-assistant/amd64-base-debian:bookworm as ml_api_base_amd64
RUN apt update && apt install --no-install-recommends -y ca-certificates python3-pip wget python3 python3-venv
COPY --from=darknet_builder /darknet /darknet
WORKDIR /app
RUN mkdir -p model
COPY rootfs/app/requirements.txt /app/requirements.txt
COPY rootfs/app/model/model-weights.darknet.url /app/model/model-weights.darknet.url
COPY rootfs/app/model/model-weights.onnx.url /app/model/model-weights.onnx.url
RUN python3 -m venv venv
ENV VIRTUAL_ENV /app/venv
ENV PATH /app/venv/bin:$PATH
RUN pip3 install --upgrade pip && \
pip3 install opencv_python_headless && \
pip3 install -r requirements.txt
RUN echo 'Downloading the latest failure detection AI model in Darknet format...' && \
wget -O model/model-weights.darknet $(cat model/model-weights.darknet.url | tr -d '\r') && \
echo 'Downloading the latest failure detection AI model in ONNX format...' && \
wget -O model/model-weights.onnx $(cat model/model-weights.onnx.url | tr -d '\r')
COPY rootfs /
RUN chmod +x /etc/services.d/ha-elegoo-spaghetti-detection/run
+20
View File
@@ -0,0 +1,20 @@
FROM thespaghettidetective/ml_api_base:1.3
WORKDIR /app
EXPOSE 3333
RUN mkdir -p model
COPY rootfs/app/requirements.txt /app/requirements.txt
COPY rootfs/app/model/model-weights.darknet.url /app/model/model-weights.darknet.url
COPY rootfs/app/model/model-weights.onnx.url /app/model/model-weights.onnx.url
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
RUN echo 'Downloading the latest failure detection AI model in Darknet format...'
RUN wget -O model/model-weights.darknet $(cat model/model-weights.darknet.url | tr -d '\r')
RUN echo 'Downloading the latest failure detection AI model in ONNX format...'
RUN wget -O model/model-weights.onnx $(cat model/model-weights.onnx.url | tr -d '\r')
ADD rootfs/app /app
ENV FLASK_APP server.py
CMD gunicorn --bind "0.0.0.0:3333" --workers "${GUNICORN_WORKERS:-1}" --timeout "${GUNICORN_TIMEOUT:-120}" --error-logfile - --log-level info wsgi
View File
+18
View File
@@ -0,0 +1,18 @@
name: "Elegoo Spaghetti Detection Server"
description: "Obico ML server for Elegoo spaghetti detection"
version: "1.0.0"
slug: "ha_elegoo_spaghetti_detection_addon"
init: false
arch:
- amd64
startup: services
ports:
3333/tcp: 3333
options:
obico_api_secret: "obico_api_secret"
use_gpu: false
gunicorn_timeout: 120
schema:
obico_api_secret: str
use_gpu: bool
gunicorn_timeout: int
+118
View File
@@ -0,0 +1,118 @@
#!python3
import cv2
from dataclasses import asdict
import json
from addon import compare_detections, Detection
import os
import argparse
import time
KNOWN_IMAGE_EXTENSIONS = ('.jpg', '.png')
KNOWN_VIDEO_EXTENSIONS = ('.mp4', '.avi')
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("image", type=str, help="Image file path")
parser.add_argument("--weights", type=str, help="Model weights file")
parser.add_argument("--det-threshold", type=float, default=0.25, help="Detection threshold")
parser.add_argument("--nms-threshold", type=float, default=0.4, help="NMS threshold")
parser.add_argument("--preheat", action='store_true', help="Make a dry run of NN for initlalization")
parser.add_argument("--cpu", action='store_true', help="Force use CPU")
parser.add_argument("--save-detections-to", type=str, help="Save detections into this file")
parser.add_argument("--compare-detections-with", type=str, help="Load detections from this file and compare with result")
parser.add_argument("--render-to", type=str, help="Save detections into this file or directory")
parser.add_argument("--print", action='store_true', help="Print detections")
opt = parser.parse_args()
net_main_1 = load_net("rootfs/model/model.cfg", "rootfs/model/model.meta", weights_path=opt.weights)
# force use CPU, only implemented for ONNX
if opt.cpu and onnx_ready and isinstance(net_main_1, OnnxNet):
net_main_1.force_cpu()
filename = os.path.basename(opt.image)
filename, extension = os.path.splitext(filename)
is_image = extension in KNOWN_IMAGE_EXTENSIONS
is_video = extension in KNOWN_VIDEO_EXTENSIONS
frame_number = 0
vwr = None
if is_video:
cap = cv2.VideoCapture(opt.image)
fps = cap.get(cv2.CAP_PROP_FPS)
frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
reading_success, custom_image_bgr = cap.read()
if opt.render_to:
fourcc = cv2.VideoWriter_fourcc("m", "p", "4", "v")
vwr = cv2.VideoWriter(opt.render_to, fourcc, fps, (frame_w, frame_h))
else:
cap = None
fps = 0.0
custom_image_bgr = cv2.imread(opt.image)
reading_success = True
# this will make library initialize all the required resources at the first run
# then the following runs will be much faster
if opt.preheat:
detections = detect(net_main_1, custom_image_bgr, thresh=opt.det_threshold, nms=opt.nms_threshold)
while reading_success:
started_at = time.time()
detections = detect(net_main_1, custom_image_bgr, thresh=opt.det_threshold, nms=opt.nms_threshold)
finished_at = time.time()
execution_time = finished_at - started_at
print(f"Frame #{frame_number} execution time: {execution_time:.3} sec, detection count: {len(detections)}")
detections = Detection.from_tuple_list(detections)
# dump detections into some file
if opt.save_detections_to:
output_filename, output_extension = os.path.splitext(opt.save_detections_to)
if is_video and not output_extension and not os.path.exists(opt.save_detections_to):
os.makedirs(opt.save_detections_to)
if os.path.isdir(opt.save_detections_to):
if is_video:
output_file_name = f"{filename}#{frame_number:04}.json"
else:
output_file_name = f"{filename}.json"
output_file_name = os.path.join(opt.save_detections_to, output_file_name)
else:
output_file_name = opt.save_detections_to
with open(output_file_name, "w") as f:
json.dump([asdict(d) for d in detections], f)
# load detections from some file and compare with detection result
if opt.compare_detections_with:
if is_video:
read_file_name = os.path.join(opt.compare_detections_with, f"{filename}#{frame_number:04}.json")
else:
read_file_name = opt.compare_detections_with
with open(read_file_name) as f:
items = json.load(f)
loaded = [Detection.from_dict(d) for d in items]
compare_result = compare_detections(loaded, detections)
if not compare_result:
print(f"Frame #{frame_number} loaded detections and resulting are different")
if opt.render_to:
for d in detections:
cv2.rectangle(custom_image_bgr,
(int(d.box.left()), int(d.box.top())), (int(d.box.right()), int(d.box.bottom())),
(0, 255, 0), 2)
if vwr:
vwr.write(custom_image_bgr)
else:
cv2.imwrite(opt.render_to, custom_image_bgr)
if opt.print:
print(detections)
if is_image:
reading_success = False
elif cap:
reading_success, custom_image_bgr = cap.read()
frame_number += 1
View File
+25
View File
@@ -0,0 +1,25 @@
import os
from functools import wraps
from flask import Response, request
ML_API_TOKEN = os.environ.get("ML_API_TOKEN")
def token_required(f):
@wraps(f)
def check_authorization(*args, **kwargs):
if (
request.headers.get("Authorization") == f"Bearer {ML_API_TOKEN}"
or request.args.get("token") == ML_API_TOKEN
):
return f(*args, **kwargs)
return Response(status=401)
@wraps(f)
def passthru(*args, **kwargs):
return f(*args, **kwargs)
if ML_API_TOKEN:
return check_authorization
return passthru
View File
+254
View File
@@ -0,0 +1,254 @@
# pylint: disable=R, W0401, W0614, W0703
from ctypes import *
import random
import os
import cv2
import platform
from typing import List, Tuple
# C-structures from Darknet lib
class BOX(Structure):
_fields_ = [("x", c_float),
("y", c_float),
("w", c_float),
("h", c_float)]
class DETECTION(Structure):
_fields_ = [("bbox", BOX),
("classes", c_int),
("best_class_idx", c_int),
("prob", POINTER(c_float)),
("mask", POINTER(c_float)),
("objectness", c_float),
("sort_class", c_int),
("uc", POINTER(c_float)),
("points", c_int),
("embeddings", POINTER(c_float)),
("embedding_size", c_int),
("sim", c_float),
("track_id", c_int)]
class IMAGE(Structure):
_fields_ = [("w", c_int),
("h", c_int),
("c", c_int),
("data", POINTER(c_float))]
class METADATA(Structure):
_fields_ = [("classes", c_int),
("names", POINTER(c_char_p))]
class YoloNet:
"""Darknet-based detector implementation"""
net: c_void_p
meta: METADATA
def __init__(self, weight_path: str, meta_path: str, config_path: str, asked_to_use_gpu: bool):
if not os.path.exists(config_path):
raise ValueError("Invalid config path `"+os.path.abspath(config_path)+"`")
if not os.path.exists(weight_path):
raise ValueError("Invalid weight path `"+os.path.abspath(weight_path)+"`")
if not os.path.exists(meta_path):
raise ValueError("Invalid data file path `"+os.path.abspath(meta_path)+"`")
if not lib:
raise ImportError(f"Unable to load darknet module.")
if asked_to_use_gpu and not using_gpu:
raise Exception('I respectfully decline to load the net as I am asked to use GPU but the loaded darknet module does NOT have GPU support')
self.net = load_net_custom(config_path.encode("ascii"), weight_path.encode("ascii"), 0, 1) # batch size = 1
self.meta = load_meta(meta_path.encode("ascii"))
def detect(self, meta, image, alt_names, thresh=.5, hier_thresh=.5, nms=.45, debug=False) -> List[Tuple[str, float, Tuple[float, float, float, float]]]:
#pylint: disable= C0321
custom_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
im, arr = array_to_image(custom_image) # you should comment line below: free_image(im)
if debug:
print("Loaded image")
num = c_int(0)
if debug:
print("Assigned num")
pnum = pointer(num)
if debug:
print("Assigned pnum")
predict_image(self.net, im)
if debug:
print("did prediction")
dets = get_network_boxes(self.net, custom_image.shape[1], custom_image.shape[0], thresh, hier_thresh, None, 0, pnum, 0) # OpenCV
if debug:
print("Got dets")
num = pnum[0]
if debug:
print("got zeroth index of pnum")
if nms:
do_nms_sort(dets, num, meta.classes, nms)
if debug:
print("did sort")
res = []
if debug:
print("about to range")
for j in range(num):
if debug:
print("Ranging on "+str(j)+" of "+str(num))
if debug:
print("Classes: "+str(meta), meta.classes, meta.names)
for i in range(meta.classes):
if debug:
print("Class-ranging on "+str(i)+" of "+str(meta.classes)+"= "+str(dets[j].prob[i]))
if dets[j].prob[i] > 0:
b = dets[j].bbox
if alt_names is None:
nameTag = meta.names[i]
else:
nameTag = alt_names[i]
if debug:
print("Got bbox", b)
print(nameTag)
print(dets[j].prob[i])
print((b.x, b.y, b.w, b.h))
res.append((nameTag, dets[j].prob[i], (b.x, b.y, b.w, b.h)))
if debug:
print("did range")
res = sorted(res, key=lambda x: -x[1])
if debug:
print("did sort")
free_detections(dets, num)
if debug:
print("freed detections")
return res
# Loads darknet shared library. May fail if some dependencies like OpenCV not installed
# libdarknet_gpu.so needs Cuda + Cudnn and other libraries in path, which may not exist
# For the such case, it will try to load libdarknet.so instead
lib = None
using_gpu = False
print('\n')
so_path = os.path.join('/darknet', "libdarknet_cpu.so")
lib = CDLL(so_path, RTLD_GLOBAL)
print(f" Darknet is now running on CPU.")
print('\n')
if lib:
lib.network_width.argtypes = [c_void_p]
lib.network_width.restype = c_int
lib.network_height.argtypes = [c_void_p]
lib.network_height.restype = c_int
predict = lib.network_predict
predict.argtypes = [c_void_p, POINTER(c_float)]
predict.restype = POINTER(c_float)
if using_gpu:
set_gpu = lib.cuda_set_device
set_gpu.argtypes = [c_int]
make_image = lib.make_image
make_image.argtypes = [c_int, c_int, c_int]
make_image.restype = IMAGE
get_network_boxes = lib.get_network_boxes
get_network_boxes.argtypes = [c_void_p, c_int, c_int, c_float, c_float, POINTER(c_int), c_int, POINTER(c_int), c_int]
get_network_boxes.restype = POINTER(DETECTION)
make_network_boxes = lib.make_network_boxes
make_network_boxes.argtypes = [c_void_p]
make_network_boxes.restype = POINTER(DETECTION)
free_detections = lib.free_detections
free_detections.argtypes = [POINTER(DETECTION), c_int]
free_ptrs = lib.free_ptrs
free_ptrs.argtypes = [POINTER(c_void_p), c_int]
network_predict = lib.network_predict
network_predict.argtypes = [c_void_p, POINTER(c_float)]
reset_rnn = lib.reset_rnn
reset_rnn.argtypes = [c_void_p]
load_net = lib.load_network
load_net.argtypes = [c_char_p, c_char_p, c_int]
load_net.restype = c_void_p
load_net_custom = lib.load_network_custom
load_net_custom.argtypes = [c_char_p, c_char_p, c_int, c_int]
load_net_custom.restype = c_void_p
do_nms_obj = lib.do_nms_obj
do_nms_obj.argtypes = [POINTER(DETECTION), c_int, c_int, c_float]
do_nms_sort = lib.do_nms_sort
do_nms_sort.argtypes = [POINTER(DETECTION), c_int, c_int, c_float]
free_image = lib.free_image
free_image.argtypes = [IMAGE]
letterbox_image = lib.letterbox_image
letterbox_image.argtypes = [IMAGE, c_int, c_int]
letterbox_image.restype = IMAGE
load_meta = lib.get_metadata
lib.get_metadata.argtypes = [c_char_p]
lib.get_metadata.restype = METADATA
load_image = lib.load_image_color
load_image.argtypes = [c_char_p, c_int, c_int]
load_image.restype = IMAGE
rgbgr_image = lib.rgbgr_image
rgbgr_image.argtypes = [IMAGE]
predict_image = lib.network_predict_image
predict_image.argtypes = [c_void_p, IMAGE]
predict_image.restype = POINTER(c_float)
def sample(probs):
s = sum(probs)
probs = [a/s for a in probs]
r = random.uniform(0, 1)
for i in range(len(probs)):
r = r - probs[i]
if r <= 0:
return i
return len(probs)-1
def c_array(ctype, values):
arr = (ctype*len(values))()
arr[:] = values
return arr
def array_to_image(arr):
import numpy as np
# need to return old values to avoid python freeing memory
arr = arr.transpose(2, 0, 1)
c = arr.shape[0]
h = arr.shape[1]
w = arr.shape[2]
arr = np.ascontiguousarray(arr.flat, dtype=np.float32) / 255.0
data = arr.ctypes.data_as(POINTER(c_float))
im = IMAGE(w, h, c, data)
return im, arr
def classify(net, meta, im):
global alt_names
out = predict_image(net, im)
res = []
for i in range(meta.classes):
if alt_names is None:
nameTag = meta.names[i]
else:
nameTag = alt_names[i]
res.append((nameTag, out[i]))
res = sorted(res, key=lambda x: -x[1])
return res
+95
View File
@@ -0,0 +1,95 @@
#!python3
# pylint: disable=R, W0401, W0614, W0703
from lib.meta import Meta
from os import environ, path
alt_names = None
darknet_ready = True
try:
from lib.darknet import YoloNet
except Exception as e:
print(f'Error during importing YoloNet! - {e}')
darknet_ready = False
onnx_ready = True
try:
from lib.onnx import OnnxNet
except Exception as e:
print(f'Error during importing OnnxNet! - {e}')
onnx_ready = False
def load_net(config_path, meta_path, weights_path=None):
def try_loading_net(net_config_priority):
for net_config in net_config_priority:
weights = net_config['weights_path']
use_gpu = net_config['use_gpu']
net_main = None
try:
print(f'----- Trying to load weights: {weights} - use_gpu = {use_gpu} -----')
if weights.endswith(".onnx"):
if not onnx_ready:
raise Exception('Not loading ONNX net due to previous import failure. Check earlier log for errors.')
net_main = OnnxNet(weights, meta_path, use_gpu)
elif weights.endswith(".darknet"):
if not darknet_ready:
raise Exception('Not loading darknet net due to previous import failure. Check earlier log for errors.')
net_main = YoloNet(weights, meta_path, config_path, use_gpu)
else:
raise Exception(f'Can not recognize net from weights file surfix: {weights}')
print('Succeeded!')
return net_main
except Exception as e:
print(f'Failed! - {e}')
raise Exception(f'Failed to load any net after trying: {net_config_priority}')
global alt_names # pylint: disable=W0603
model_dir = path.join(path.dirname(path.realpath(__file__)), '..', 'model')
use_gpu = environ.get('ML_USE_GPU', 'false').lower() in ('1', 'true', 'yes', 'on')
preferred_backend = environ.get('ML_MODEL_BACKEND', 'onnx').lower()
cpu_priority = [
dict(weights_path=path.join(model_dir, 'model-weights.onnx'), use_gpu=False),
dict(weights_path=path.join(model_dir, 'model-weights.darknet'), use_gpu=False),
]
gpu_priority = [
dict(weights_path=path.join(model_dir, 'model-weights.onnx'), use_gpu=True),
dict(weights_path=path.join(model_dir, 'model-weights.darknet'), use_gpu=True),
]
if preferred_backend == 'darknet':
cpu_priority.reverse()
gpu_priority.reverse()
net_config_priority = gpu_priority + cpu_priority if use_gpu else cpu_priority
if weights_path is not None:
net_config_priority = (
[dict(weights_path=weights_path, use_gpu=True), dict(weights_path=weights_path, use_gpu=False)]
if use_gpu
else [dict(weights_path=weights_path, use_gpu=False)]
)
net_main = try_loading_net(net_config_priority)
if alt_names is None:
# In Python 3, the metafile default access craps out on Windows (but not Linux)
# Read the names file and create a list to feed to detect
try:
meta = Meta(meta_path)
alt_names = meta.names
except Exception:
pass
return net_main
def detect(net, image, thresh=.5, hier_thresh=.5, nms=.45, debug=False):
return net.detect(net.meta, image, alt_names, thresh, hier_thresh, nms, debug)
+111
View File
@@ -0,0 +1,111 @@
from dataclasses import dataclass, asdict
from typing import Any, Dict, List, Tuple
@dataclass
class Box:
"""Detection rect"""
xc: float
yc: float
w: float
h: float
@classmethod
def from_tuple(cls, box: Tuple[float, float, float, float]) -> 'Box':
return Box(xc=float(box[0]), yc=float(box[1]), w=float(box[2]), h=float(box[3]))
def left(self) -> float:
return self.xc - self.w * 0.5
def right(self) -> float:
return self.xc + self.w * 0.5
def top(self) -> float:
return self.yc - self.h * 0.5
def bottom(self) -> float:
return self.yc + self.h * 0.5
def calc_iou(self, other: 'Box') -> float:
"""Calculates intersection over union ration which can be used to compare boxes"""
al = self.left()
ar = self.right()
at = self.top()
ab = self.bottom()
bl = other.left()
br = other.right()
bt = other.top()
bb = other.bottom()
i_l = max(al, bl)
i_r = min(ar, br)
i_t = max(at, bt)
i_b = min(ab, bb)
o_l = min(al, bl)
o_r = max(ar, br)
o_t = min(at, bt)
o_b = max(ab, bb)
i_w = i_r - i_l
i_h = i_b - i_t
o_w = o_r - o_l
o_h = o_b - o_t
o_a = o_w * o_h
if o_a <= 0.0:
return 0.0
return i_w * i_h / o_a
@dataclass
class Detection:
"""Detection result"""
name: str
confidence: float
box: Box
@classmethod
def from_tuple_list(cls, detections: List[Tuple[str, float, Tuple[float, float, float, float]]]) -> List['Detection']:
return [Detection.from_tuple(d) for d in detections]
@classmethod
def from_tuple(cls, detection: Tuple[str, float, Tuple[float, float, float, float]]) -> 'Detection':
box = Box.from_tuple(detection[2])
return Detection(detection[0], float(detection[1]), box)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Detection':
return Detection(data['name'], data['confidence'], Box(**data['box']))
def compare_detections(l1: List[Detection], l2: List[Detection], threshold: float = 0.4) -> bool:
"""Compares two lists of detections. Returns true if lists looks similar with some threshold"""
# Are there all boxes from l1 matching any in l2
for a in l1:
found = False
for b in l2:
iou = a.box.calc_iou(b.box)
if iou >= threshold:
found = True
break
if not found:
return False
# are there all boxes in l2 matching any in l1
# the list may differ and contain duplicates,
# that's why we need two checks
for b in l2:
found = False
for a in l1:
iou = a.box.calc_iou(b.box)
if iou >= threshold:
found = True
break
if not found:
return False
return True
+27
View File
@@ -0,0 +1,27 @@
from typing import List, Tuple
from dataclasses import dataclass, field
import os
import re
@dataclass
class Meta:
names: List[str] = field(default_factory=list)
def __init__(self, meta_path: str):
names = None
with open(meta_path) as f:
meta_contents = f.read()
match = re.search("names *= *(.*)$", meta_contents, re.IGNORECASE | re.MULTILINE)
if match:
names_path = match.group(1)
try:
if os.path.exists(names_path):
with open(names_path) as namesFH:
names_list = namesFH.read().strip().split("\n")
names = [x.strip() for x in names_list]
except TypeError:
pass
if names is None:
names = ['failure']
self.names = names
+132
View File
@@ -0,0 +1,132 @@
from typing import List, Tuple
import onnxruntime
import numpy as np
import cv2
import os
from lib.meta import Meta
class OnnxNet:
session: onnxruntime.InferenceSession
meta: Meta
def __init__(self, onnx_path: str, meta_path: str, use_gpu: bool):
providers = ['CUDAExecutionProvider'] if use_gpu else ['CPUExecutionProvider']
self.session = onnxruntime.InferenceSession(onnx_path, providers=providers)
self.meta = Meta(meta_path)
def detect(self, meta, image, alt_names, thresh=.5, hier_thresh=.5, nms=.45, debug=False) -> List[Tuple[str, float, Tuple[float, float, float, float]]]:
input_h = self.session.get_inputs()[0].shape[2]
input_w = self.session.get_inputs()[0].shape[3]
width = image.shape[1]
height = image.shape[0]
# Input
resized = cv2.resize(image, (input_w, input_h), interpolation=cv2.INTER_LINEAR)
img_in = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
img_in = np.transpose(img_in, (2, 0, 1)).astype(np.float32)
img_in = np.expand_dims(img_in, axis=0)
img_in /= 255.0
input_name = self.session.get_inputs()[0].name
outputs = self.session.run(None, {input_name: img_in})
detections = post_processing(outputs, width, height, thresh, nms, meta.names)
return detections[0]
def nms_cpu(boxes, confs, nms_thresh=0.5, min_mode=False):
# print(boxes.shape)
x1 = boxes[:, 0]
y1 = boxes[:, 1]
x2 = boxes[:, 2]
y2 = boxes[:, 3]
areas = (x2 - x1) * (y2 - y1)
order = confs.argsort()[::-1]
keep = []
while order.size > 0:
idx_self = order[0]
idx_other = order[1:]
keep.append(idx_self)
xx1 = np.maximum(x1[idx_self], x1[idx_other])
yy1 = np.maximum(y1[idx_self], y1[idx_other])
xx2 = np.minimum(x2[idx_self], x2[idx_other])
yy2 = np.minimum(y2[idx_self], y2[idx_other])
w = np.maximum(0.0, xx2 - xx1)
h = np.maximum(0.0, yy2 - yy1)
inter = w * h
if min_mode:
over = inter / np.minimum(areas[order[0]], areas[order[1:]])
else:
over = inter / (areas[order[0]] + areas[order[1:]] - inter)
inds = np.where(over <= nms_thresh)[0]
order = order[inds + 1]
return np.array(keep)
def post_processing(output, width, height, conf_thresh, nms_thresh, names):
box_array = output[0]
confs = output[1]
if type(box_array).__name__ != 'ndarray':
box_array = box_array.cpu().detach().numpy()
confs = confs.cpu().detach().numpy()
num_classes = confs.shape[2]
# [batch, num, 4]
box_array = box_array[:, :, 0]
# [batch, num, num_classes] --> [batch, num]
max_conf = np.max(confs, axis=2)
max_id = np.argmax(confs, axis=2)
box_x1x1x2y2_to_xcycwh_scaled = lambda b: \
(
float(0.5 * width * (b[0] + b[2])),
float(0.5 * height * (b[1] + b[3])),
float(width * (b[2] - b[0])),
float(width * (b[3] - b[1]))
)
dets_batch = []
for i in range(box_array.shape[0]):
argwhere = max_conf[i] > conf_thresh
l_box_array = box_array[i, argwhere, :]
l_max_conf = max_conf[i, argwhere]
l_max_id = max_id[i, argwhere]
bboxes = []
# nms for each class
for j in range(num_classes):
cls_argwhere = l_max_id == j
ll_box_array = l_box_array[cls_argwhere, :]
ll_max_conf = l_max_conf[cls_argwhere]
ll_max_id = l_max_id[cls_argwhere]
keep = nms_cpu(ll_box_array, ll_max_conf, nms_thresh)
if (keep.size > 0):
ll_box_array = ll_box_array[keep, :]
ll_max_conf = ll_max_conf[keep]
ll_max_id = ll_max_id[keep]
for k in range(ll_box_array.shape[0]):
bboxes.append([ll_box_array[k, 0], ll_box_array[k, 1], ll_box_array[k, 2], ll_box_array[k, 3], ll_max_conf[k], ll_max_conf[k], ll_max_id[k]])
detections = [(names[b[6]], float(b[4]), box_x1x1x2y2_to_xcycwh_scaled((b[0], b[1], b[2], b[3]))) for b in bboxes]
dets_batch.append(detections)
return dets_batch
@@ -0,0 +1 @@
https://tsd-pub-static.s3.amazonaws.com/ml-models/model-weights-8be06cde4e.darknet
@@ -0,0 +1 @@
https://tsd-pub-static.s3.amazonaws.com/ml-models/model-weights-5a6b1be1fa.onnx
+258
View File
@@ -0,0 +1,258 @@
[net]
# Testing
batch=64
subdivisions=8
# Training
# batch=64
# subdivisions=8
height=416
width=416
channels=3
momentum=0.9
decay=0.0005
angle=0
saturation = 1.5
exposure = 1.5
hue=.1
learning_rate=0.001
burn_in=1000
max_batches = 50000
policy=steps
steps=40000,60000
scales=.1,.1
[convolutional]
batch_normalize=1
filters=32
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=64
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=128
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=64
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=128
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=256
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=128
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=256
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=512
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=256
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=512
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=256
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=512
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=1024
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=512
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=1024
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=512
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=1024
size=3
stride=1
pad=1
activation=leaky
#######
[convolutional]
batch_normalize=1
size=3
stride=1
pad=1
filters=1024
activation=leaky
[convolutional]
batch_normalize=1
size=3
stride=1
pad=1
filters=1024
activation=leaky
[route]
layers=-9
[convolutional]
batch_normalize=1
size=1
stride=1
pad=1
filters=64
activation=leaky
[reorg3d]
stride=2
[route]
layers=-1,-4
[convolutional]
batch_normalize=1
size=3
stride=1
pad=1
filters=1024
activation=leaky
[convolutional]
size=1
stride=1
pad=1
filters=30
activation=linear
[region]
anchors = 1.3221, 1.73145, 3.19275, 4.00944, 5.05587, 8.09892, 9.47112, 4.84053, 11.2364, 10.0071
bias_match=1
classes=1
coords=4
num=5
softmax=1
jitter=.3
rescore=1
object_scale=5
noobject_scale=1
class_scale=1
coord_scale=1
absolute=1
thresh = .6
random=1
+2
View File
@@ -0,0 +1,2 @@
classes= 1
names = /app/model/names
+1
View File
@@ -0,0 +1 @@
failure
+6
View File
@@ -0,0 +1,6 @@
ipdb
flask>=1.0
redis==3.0.1
newrelic==4.12.0.113
requests==2.21.0
gunicorn==19.9.0
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python
from __future__ import annotations
from collections import deque
from datetime import datetime, timezone
import logging
from os import environ, path
from time import perf_counter
from urllib.parse import urlsplit, urlunsplit
import cv2
import flask
from flask import Response, jsonify, request
import numpy as np
import requests
from auth import token_required
from lib.detection_model import detect, load_net
THRESH = float(environ.get("ML_DETECTION_BOX_THRESHOLD", "0.08"))
REQUEST_TIMEOUT = (
float(environ.get("ML_IMAGE_CONNECT_TIMEOUT", "2")),
float(environ.get("ML_IMAGE_READ_TIMEOUT", "10")),
)
MAX_RECENT_REQUESTS = int(environ.get("ML_RECENT_REQUESTS", "100"))
app = flask.Flask(__name__)
app.config["DEBUG"] = environ.get("DEBUG") == "True"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
app.logger.setLevel(logging.INFO)
STARTED_AT = datetime.now(timezone.utc)
RECENT_REQUESTS: deque[dict] = deque(maxlen=MAX_RECENT_REQUESTS)
model_dir = path.join(path.dirname(path.realpath(__file__)), "model")
net_main = load_net(path.join(model_dir, "model.cfg"), path.join(model_dir, "model.meta"))
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _redact_url(raw_url: str | None) -> str | None:
if not raw_url:
return None
parsed = urlsplit(raw_url)
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
def _record_request(entry: dict) -> None:
stored_entry = dict(entry)
if "image_url" in stored_entry:
stored_entry["image_url"] = _redact_url(stored_entry.get("image_url"))
RECENT_REQUESTS.appendleft({"time": _now_iso(), **stored_entry})
app.logger.info(
"prediction status=%s detections=%s duration_ms=%s image_host=%s error=%s",
entry.get("status"),
entry.get("detections", 0),
entry.get("duration_ms"),
urlsplit(entry.get("image_url") or "").netloc,
entry.get("error"),
)
def _fetch_image(image_url: str) -> np.ndarray:
response = requests.get(image_url, stream=True, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
img_array = np.array(bytearray(response.content), dtype=np.uint8)
image = cv2.imdecode(img_array, -1)
if image is None:
raise ValueError("image_decode_failed")
return image
def _status_payload() -> dict:
return {
"ok": net_main is not None,
"started_at": STARTED_AT.isoformat(),
"model": {
"classes": ["failure"],
"box_threshold": THRESH,
"backend": type(net_main).__name__ if net_main is not None else None,
"use_gpu": environ.get("ML_USE_GPU", "false"),
"model_backend_preference": environ.get("ML_MODEL_BACKEND", "onnx"),
},
"requests": {
"recent_count": len(RECENT_REQUESTS),
"max_recent": MAX_RECENT_REQUESTS,
},
}
@app.route("/", methods=["GET"])
def dashboard():
"""Render a small operational status page."""
status = _status_payload()
rows = "\n".join(
"<tr>"
f"<td>{entry['time']}</td>"
f"<td>{entry.get('status', '')}</td>"
f"<td>{entry.get('detections', 0)}</td>"
f"<td>{entry.get('duration_ms', '')}</td>"
f"<td>{entry.get('error') or ''}</td>"
f"<td>{_redact_url(entry.get('image_url')) or ''}</td>"
"</tr>"
for entry in list(RECENT_REQUESTS)[:20]
)
body = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Elegoo Spaghetti Detection ML Server</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 24px; color: #1f2933; }}
code {{ background: #eef2f7; padding: 2px 5px; border-radius: 4px; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 16px; }}
th, td {{ border-bottom: 1px solid #d9e2ec; padding: 8px; text-align: left; font-size: 14px; }}
.ok {{ color: #137333; font-weight: 700; }}
.bad {{ color: #b3261e; font-weight: 700; }}
</style>
</head>
<body>
<h1>Elegoo Spaghetti Detection ML Server</h1>
<p>Status: <span class="{'ok' if status['ok'] else 'bad'}">{'ok' if status['ok'] else 'error'}</span></p>
<p>Backend: <code>{status['model']['backend']}</code> | GPU opt-in: <code>{status['model']['use_gpu']}</code> | Box threshold: <code>{status['model']['box_threshold']}</code></p>
<p>Health: <code>/hc/</code> | JSON status: <code>/api/status</code> | Token-protected logs: <code>/api/logs?token=&lt;token&gt;</code></p>
<h2>Recent Requests</h2>
<table>
<thead><tr><th>Time</th><th>Status</th><th>Detections</th><th>ms</th><th>Error</th><th>Image URL without token</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</body>
</html>"""
return Response(body, mimetype="text/html")
@app.route("/api/status", methods=["GET"])
def api_status():
"""Return JSON server status."""
return jsonify(_status_payload())
@app.route("/api/logs", methods=["GET"])
@token_required
def api_logs():
"""Return recent request logs."""
return jsonify({"requests": list(RECENT_REQUESTS)})
@app.route("/debug/image", methods=["GET"])
@token_required
def debug_image():
"""Check whether the server can fetch and decode an image URL."""
image_url = request.args.get("img")
if not image_url:
return jsonify({"ok": False, "error": "missing_image_url"}), 400
started = perf_counter()
try:
image = _fetch_image(image_url)
return jsonify(
{
"ok": True,
"duration_ms": round((perf_counter() - started) * 1000),
"shape": list(image.shape),
"image_url": _redact_url(image_url),
}
)
except requests.RequestException as err:
return jsonify({"ok": False, "error": "image_fetch_failed", "message": str(err)}), 502
except ValueError as err:
return jsonify({"ok": False, "error": str(err)}), 422
@app.route("/p/", methods=["GET"])
@token_required
def get_p():
"""Run prediction for the image URL in the img query parameter."""
image_url = request.args.get("img")
if not image_url:
_record_request({"status": 400, "error": "missing_image_url", "detections": 0})
return jsonify(
{
"detections": [],
"error": "missing_image_url",
"message": "Missing img query parameter.",
}
), 400
started = perf_counter()
try:
image = _fetch_image(image_url)
detections = detect(net_main, image, thresh=THRESH)
duration_ms = round((perf_counter() - started) * 1000)
_record_request(
{
"status": 200,
"detections": len(detections),
"duration_ms": duration_ms,
"image_url": image_url,
}
)
return jsonify({"detections": detections, "duration_ms": duration_ms})
except requests.RequestException as err:
duration_ms = round((perf_counter() - started) * 1000)
_record_request(
{
"status": 502,
"error": "image_fetch_failed",
"message": str(err),
"duration_ms": duration_ms,
"image_url": image_url,
"detections": 0,
}
)
return jsonify(
{
"detections": [],
"error": "image_fetch_failed",
"message": str(err),
}
), 502
except ValueError as err:
duration_ms = round((perf_counter() - started) * 1000)
_record_request(
{
"status": 422,
"error": str(err),
"duration_ms": duration_ms,
"image_url": image_url,
"detections": 0,
}
)
return jsonify(
{
"detections": [],
"error": str(err),
"message": "The image URL did not return a decodable image.",
}
), 422
except Exception as err:
duration_ms = round((perf_counter() - started) * 1000)
app.logger.exception("Unable to process image")
_record_request(
{
"status": 500,
"error": "prediction_failed",
"message": str(err),
"duration_ms": duration_ms,
"image_url": image_url,
"detections": 0,
}
)
return jsonify(
{
"detections": [],
"error": "prediction_failed",
"message": str(err),
}
), 500
@app.route("/hc/", methods=["GET"])
def health_check():
"""Health check for Home Assistant and Docker."""
if net_main is not None:
return "ok", 200
return "error", 503
if __name__ == "__main__":
app.run(host="0.0.0.0", port=3333, threaded=False)
+6
View File
@@ -0,0 +1,6 @@
import server
application = server.app
if __name__ == "__main__":
application.run()
@@ -0,0 +1,17 @@
#!/usr/bin/with-contenv bashio
declare ML_API_TOKEN
ML_API_TOKEN=$(bashio::config 'obico_api_secret')
export ML_API_TOKEN
export ML_USE_GPU=$(bashio::config 'use_gpu')
export GUNICORN_TIMEOUT=$(bashio::config 'gunicorn_timeout')
cd /app
FLASK_APP=server.py venv/bin/gunicorn \
--bind "0.0.0.0:3333" \
--workers "${GUNICORN_WORKERS:-1}" \
--timeout "${GUNICORN_TIMEOUT:-120}" \
--error-logfile - \
--log-level info \
wsgi:application
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bashio
set -e
ML_API_TOKEN=$(bashio::config 'obico_api_secret')
PORT=$(bashio::addon.port 3333)
export ML_API_TOKEN
export ML_USE_GPU=$(bashio::config 'use_gpu')
export GUNICORN_TIMEOUT=$(bashio::config 'gunicorn_timeout')
venv/bin/gunicorn \
--bind "0.0.0.0:$PORT" \
--workers "${GUNICORN_WORKERS:-1}" \
--timeout "${GUNICORN_TIMEOUT:-120}" \
--error-logfile - \
--log-level info \
wsgi
@@ -0,0 +1,179 @@
"""Home Assistant integration for Elegoo spaghetti detection."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
CONF_CONFIG_ENTRY,
CONF_DETECTOR,
CONF_FORCE,
CONF_IMAGE_URL,
CONF_OBICO_AUTH_TOKEN,
CONF_OBICO_HOST,
DOMAIN,
PLATFORMS,
REQUIRED_CONFIG_KEYS,
RUNTIME_ML_LOCK,
RUNTIME_BY_DETECTOR,
RUNTIME_DATA,
SERVICE_PREDICT,
SERVICE_RESET_STATE,
SERVICE_RUN_DETECTION,
)
from .runtime import SpaghettiDetectorRuntime
LOGGER = logging.getLogger(__package__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
PREDICT_SCHEMA = vol.Schema(
{
vol.Required(CONF_OBICO_HOST): str,
vol.Required(CONF_OBICO_AUTH_TOKEN): str,
vol.Required(CONF_IMAGE_URL): str,
}
)
DETECTOR_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_CONFIG_ENTRY): str,
vol.Optional(CONF_DETECTOR): str,
vol.Optional(CONF_FORCE, default=True): bool,
}
)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up global services for Elegoo spaghetti detection."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN].setdefault(RUNTIME_DATA, {})
hass.data[DOMAIN].setdefault(RUNTIME_BY_DETECTOR, {})
hass.data[DOMAIN].setdefault(RUNTIME_ML_LOCK, asyncio.Lock())
async def predict_handler(call: ServiceCall) -> ServiceResponse:
"""Run the Obico ML model for a raw image URL."""
result = await _async_predict_raw(
hass,
call.data[CONF_OBICO_HOST],
call.data[CONF_OBICO_AUTH_TOKEN],
call.data[CONF_IMAGE_URL],
)
return {"result": result}
async def run_detection_handler(call: ServiceCall) -> ServiceResponse:
"""Run one detection against the configured detector."""
runtime = _runtime_from_call(hass, call)
return await runtime.async_run_detection(manual=bool(call.data[CONF_FORCE]))
async def reset_handler(call: ServiceCall) -> None:
"""Reset detector state."""
runtime = _runtime_from_call(hass, call)
runtime.reset()
hass.services.async_register(
DOMAIN,
SERVICE_PREDICT,
predict_handler,
schema=PREDICT_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_RUN_DETECTION,
run_detection_handler,
schema=DETECTOR_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_RESET_STATE,
reset_handler,
schema=DETECTOR_SERVICE_SCHEMA,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up one spaghetti detector."""
missing = sorted(
key
for key in REQUIRED_CONFIG_KEYS
if key not in entry.data and key not in entry.options
)
if missing:
LOGGER.error(
"Config entry %s is incomplete and must be removed and recreated. Missing: %s",
entry.title,
", ".join(missing),
)
return False
runtime = SpaghettiDetectorRuntime(hass, entry)
hass.data[DOMAIN][RUNTIME_DATA][entry.entry_id] = runtime
hass.data[DOMAIN][RUNTIME_BY_DETECTOR][runtime.detector_id] = runtime
await runtime.async_setup()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload one spaghetti detector."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
runtime = hass.data[DOMAIN][RUNTIME_DATA].pop(entry.entry_id, None)
if runtime is not None:
hass.data[DOMAIN][RUNTIME_BY_DETECTOR].pop(runtime.detector_id, None)
await runtime.async_unload()
return unload_ok
async def _async_predict_raw(
hass: HomeAssistant,
obico_host: str,
obico_auth_token: str,
image_url: str,
) -> dict[str, Any]:
"""Call Obico ML directly."""
try:
session = async_get_clientsession(hass)
async with session.get(
f"{obico_host.rstrip('/')}/p/",
params={"img": image_url},
headers={"Authorization": f"Bearer {obico_auth_token}"},
timeout=aiohttp.ClientTimeout(total=60),
) as response:
response.raise_for_status()
result = await response.json()
if not isinstance(result, dict):
return {"detections": []}
return result
except (aiohttp.ClientError, TimeoutError) as err:
LOGGER.warning("Obico ML request failed: %s", err)
return {"detections": []}
def _runtime_from_call(
hass: HomeAssistant,
call: ServiceCall,
) -> SpaghettiDetectorRuntime:
"""Resolve a runtime from a service call."""
runtime: SpaghettiDetectorRuntime | None = None
if config_entry_id := call.data.get(CONF_CONFIG_ENTRY):
runtime = hass.data[DOMAIN][RUNTIME_DATA].get(config_entry_id)
elif detector := call.data.get(CONF_DETECTOR):
runtime = hass.data[DOMAIN][RUNTIME_BY_DETECTOR].get(detector)
if runtime is None:
raise HomeAssistantError("Unknown Elegoo spaghetti detector")
return runtime
@@ -0,0 +1,47 @@
"""Binary sensors for Elegoo spaghetti detection."""
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import CONF_INSTANCE_ID, DOMAIN, RUNTIME_DATA
from .entity import SpaghettiDetectorEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities,
) -> None:
"""Set up binary sensors."""
runtime = hass.data[DOMAIN][RUNTIME_DATA][entry.entry_id]
async_add_entities([SpaghettiDetectedBinarySensor(entry, runtime)])
class SpaghettiDetectedBinarySensor(SpaghettiDetectorEntity, BinarySensorEntity):
"""Spaghetti detected state."""
_attr_name = "Spaghetti Detected"
_attr_icon = "mdi:alert-octagram"
def __init__(self, entry: ConfigEntry, runtime) -> None:
super().__init__(entry, runtime, "spaghetti_detected")
self.entity_id = (
f"binary_sensor.{entry.data[CONF_INSTANCE_ID]}_spaghetti_detected"
)
@property
def is_on(self) -> bool:
"""Return true if spaghetti was detected."""
return self.runtime.detected
@property
def extra_state_attributes(self) -> dict:
"""Return debug attributes."""
return {
"confidence": self.runtime.confidence,
"raw_score": self.runtime.raw_score,
"warning": self.runtime.warning,
"detections": self.runtime.detection_count,
"last_error": self.runtime.last_error,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -0,0 +1,77 @@
"""Buttons for Elegoo spaghetti detection."""
from dataclasses import dataclass
from typing import Awaitable, Callable
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import CONF_INSTANCE_ID, DOMAIN, RUNTIME_DATA
from .entity import SpaghettiDetectorEntity
from .runtime import SpaghettiDetectorRuntime
@dataclass(frozen=True, kw_only=True)
class DetectorButtonDescription(ButtonEntityDescription):
"""Detector button description."""
press_fn: Callable[[SpaghettiDetectorRuntime], Awaitable[None]]
async def _run_detection(runtime: SpaghettiDetectorRuntime) -> None:
"""Run one manual detection."""
await runtime.async_run_detection(manual=True)
async def _reset_state(runtime: SpaghettiDetectorRuntime) -> None:
"""Reset detection state."""
runtime.reset()
BUTTONS: tuple[DetectorButtonDescription, ...] = (
DetectorButtonDescription(
key="test_spaghetti_detection",
name="Test Spaghetti Detection",
icon="mdi:camera-iris",
press_fn=_run_detection,
),
DetectorButtonDescription(
key="reset_detection_state",
name="Reset Detection State",
icon="mdi:restart",
press_fn=_reset_state,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities,
) -> None:
"""Set up buttons."""
runtime = hass.data[DOMAIN][RUNTIME_DATA][entry.entry_id]
async_add_entities(
DetectorButton(entry, runtime, description) for description in BUTTONS
)
class DetectorButton(SpaghettiDetectorEntity, ButtonEntity):
"""Detector action button."""
entity_description: DetectorButtonDescription
def __init__(
self,
entry: ConfigEntry,
runtime: SpaghettiDetectorRuntime,
description: DetectorButtonDescription,
) -> None:
super().__init__(entry, runtime, description.key)
self.entity_description = description
self.entity_id = f"button.{entry.data[CONF_INSTANCE_ID]}_{description.key}"
async def async_press(self) -> None:
"""Handle button press."""
await self.entity_description.press_fn(self.runtime)
@@ -0,0 +1,499 @@
"""Config flow for Elegoo spaghetti detection."""
from __future__ import annotations
from typing import Any
import aiohttp
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import slugify
import voluptuous as vol
from .const import (
CONF_ACTIVE_PRINT_STATES,
CONF_CAMERA,
CONF_CHAMBER_LIGHT,
CONF_COOLDOWN_SECONDS,
CONF_DETECTION_INTERVAL,
CONF_FAILURE_THRESHOLD,
CONF_HOME_ASSISTANT_HOST,
CONF_INSTANCE_ID,
CONF_LIGHT_CONTROL_MODE,
CONF_LIGHT_SETTLE_SECONDS,
CONF_OBICO_AUTH_TOKEN,
CONF_OBICO_HOST,
CONF_PRINT_STATUS_SENSOR,
CONF_RUN_WITHOUT_PRINTING,
CONF_SENSITIVITY,
CONF_SNAPSHOT_URL,
CONF_WARNING_THRESHOLD,
DEFAULT_ACTIVE_PRINT_STATES,
DEFAULT_COOLDOWN_SECONDS,
DEFAULT_DETECTION_INTERVAL,
DEFAULT_FAILURE_THRESHOLD,
DEFAULT_HOME_ASSISTANT_HOST,
DEFAULT_INSTANCE_ID,
DEFAULT_LIGHT_CONTROL_MODE,
DEFAULT_LIGHT_SETTLE_SECONDS,
DEFAULT_NAME,
DEFAULT_OBICO_AUTH_TOKEN,
DEFAULT_OBICO_HOST,
DEFAULT_SENSITIVITY,
DEFAULT_WARNING_THRESHOLD,
DOMAIN,
LIGHT_CONTROL_LEAVE_ON,
LIGHT_CONTROL_OFF,
LIGHT_CONTROL_RESTORE,
)
OPTIONAL_ENTITY_FIELDS: tuple[tuple[str, str | list[str]], ...] = (
(CONF_PRINT_STATUS_SENSOR, ["sensor", "binary_sensor"]),
(CONF_CHAMBER_LIGHT, "light"),
)
def _entry_values(entry: ConfigEntry) -> dict[str, Any]:
"""Return config entry data with options overriding editable settings."""
return {**entry.data, **entry.options}
def _default_value(defaults: dict[str, Any], key: str, fallback: Any) -> Any:
"""Return a form default without leaking None into selectors."""
value = defaults.get(key)
return fallback if value is None else value
def _optional_marker(key: str, defaults: dict[str, Any]) -> vol.Optional:
"""Return an optional voluptuous marker with an existing default if present."""
if defaults.get(key):
return vol.Optional(key, default=defaults[key])
return vol.Optional(key)
def _light_control_mode(defaults: dict[str, Any]) -> str:
"""Return the default light-control mode for setup/options forms."""
mode = defaults.get(CONF_LIGHT_CONTROL_MODE)
if mode in {LIGHT_CONTROL_OFF, LIGHT_CONTROL_LEAVE_ON, LIGHT_CONTROL_RESTORE}:
return mode
return DEFAULT_LIGHT_CONTROL_MODE
def _schema(
defaults: dict[str, Any] | None = None,
*,
include_identity: bool,
) -> vol.Schema:
"""Return detector setup/options schema."""
defaults = defaults or {}
data_schema: dict[Any, Any] = {}
if include_identity:
data_schema[
vol.Required(
CONF_NAME,
default=_default_value(defaults, CONF_NAME, DEFAULT_NAME),
)
] = str
data_schema[
vol.Required(
CONF_INSTANCE_ID,
default=_default_value(
defaults,
CONF_INSTANCE_ID,
DEFAULT_INSTANCE_ID,
),
)
] = str
data_schema[
vol.Required(
CONF_HOME_ASSISTANT_HOST,
default=_default_value(
defaults,
CONF_HOME_ASSISTANT_HOST,
DEFAULT_HOME_ASSISTANT_HOST,
),
)
] = str
data_schema[
vol.Required(
CONF_OBICO_HOST,
default=_default_value(defaults, CONF_OBICO_HOST, DEFAULT_OBICO_HOST),
)
] = str
data_schema[
vol.Required(
CONF_OBICO_AUTH_TOKEN,
default=_default_value(
defaults,
CONF_OBICO_AUTH_TOKEN,
DEFAULT_OBICO_AUTH_TOKEN,
),
)
] = str
camera_marker = (
vol.Required(CONF_CAMERA, default=defaults[CONF_CAMERA])
if defaults.get(CONF_CAMERA)
else vol.Required(CONF_CAMERA)
)
data_schema[camera_marker] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="camera")
)
data_schema[
vol.Optional(
CONF_SNAPSHOT_URL,
default=_default_value(defaults, CONF_SNAPSHOT_URL, ""),
)
] = str
for key, domain in OPTIONAL_ENTITY_FIELDS:
data_schema[_optional_marker(key, defaults)] = selector.EntitySelector(
selector.EntitySelectorConfig(domain=domain)
)
data_schema[
vol.Required(
CONF_ACTIVE_PRINT_STATES,
default=_default_value(
defaults,
CONF_ACTIVE_PRINT_STATES,
DEFAULT_ACTIVE_PRINT_STATES,
),
)
] = str
data_schema[
vol.Required(
CONF_LIGHT_CONTROL_MODE,
default=_light_control_mode(defaults),
)
] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
{"label": "Do not control light", "value": LIGHT_CONTROL_OFF},
{
"label": "Turn on before detection and leave on",
"value": LIGHT_CONTROL_LEAVE_ON,
},
{
"label": "Restore previous state after detection",
"value": LIGHT_CONTROL_RESTORE,
},
],
mode="dropdown",
)
)
data_schema[
vol.Required(
CONF_LIGHT_SETTLE_SECONDS,
default=_default_value(
defaults,
CONF_LIGHT_SETTLE_SECONDS,
DEFAULT_LIGHT_SETTLE_SECONDS,
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=30,
step=1,
mode="box",
unit_of_measurement="s",
)
)
data_schema[
vol.Required(
CONF_RUN_WITHOUT_PRINTING,
default=_default_value(defaults, CONF_RUN_WITHOUT_PRINTING, False),
)
] = selector.BooleanSelector()
data_schema[
vol.Required(
CONF_DETECTION_INTERVAL,
default=_default_value(
defaults,
CONF_DETECTION_INTERVAL,
DEFAULT_DETECTION_INTERVAL,
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=5,
max=3600,
step=5,
mode="box",
unit_of_measurement="s",
)
)
data_schema[
vol.Required(
CONF_SENSITIVITY,
default=_default_value(defaults, CONF_SENSITIVITY, DEFAULT_SENSITIVITY),
)
] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
{"label": "High sensitivity", "value": "high"},
{"label": "Normal sensitivity", "value": "normal"},
{"label": "Low sensitivity", "value": "low"},
{"label": "Custom thresholds", "value": "custom"},
],
mode="dropdown",
)
)
data_schema[
vol.Required(
CONF_WARNING_THRESHOLD,
default=_default_value(
defaults,
CONF_WARNING_THRESHOLD,
DEFAULT_WARNING_THRESHOLD,
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=1, step=0.01, mode="box")
)
data_schema[
vol.Required(
CONF_FAILURE_THRESHOLD,
default=_default_value(
defaults,
CONF_FAILURE_THRESHOLD,
DEFAULT_FAILURE_THRESHOLD,
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=1, step=0.01, mode="box")
)
data_schema[
vol.Required(
CONF_COOLDOWN_SECONDS,
default=_default_value(
defaults,
CONF_COOLDOWN_SECONDS,
DEFAULT_COOLDOWN_SECONDS,
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=3600,
step=5,
mode="box",
unit_of_measurement="s",
)
)
return vol.Schema(data_schema)
def _build_image_url(
hass,
data: dict[str, Any],
) -> str | None:
"""Build the image URL that the ML server will fetch during checks."""
if snapshot_url := data.get(CONF_SNAPSHOT_URL):
return snapshot_url
state = hass.states.get(data[CONF_CAMERA])
if state is None:
return None
entity_picture = state.attributes.get("entity_picture")
if not entity_picture:
return None
return f"{data[CONF_HOME_ASSISTANT_HOST].rstrip('/')}{entity_picture}"
def _validate_thresholds(data: dict[str, Any]) -> dict[str, str]:
"""Validate threshold fields."""
if float(data[CONF_WARNING_THRESHOLD]) > float(data[CONF_FAILURE_THRESHOLD]):
return {CONF_WARNING_THRESHOLD: "warning_above_failure"}
return {}
def _camera_in_use(
entries: list[ConfigEntry],
camera: str,
*,
exclude_entry_id: str | None = None,
) -> bool:
"""Return whether a camera is already used by a detector."""
return any(
entry.entry_id != exclude_entry_id
and _entry_values(entry).get(CONF_CAMERA) == camera
for entry in entries
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Elegoo spaghetti detection."""
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create the options flow."""
return OptionsFlowHandler()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Configure one detector target."""
errors: dict[str, str] = {}
form_defaults = self._defaults_from_existing_entry()
if user_input is not None:
data = dict(user_input)
data[CONF_INSTANCE_ID] = slugify(data[CONF_INSTANCE_ID])
errors.update(_validate_thresholds(data))
if not data[CONF_INSTANCE_ID]:
errors[CONF_INSTANCE_ID] = "invalid_instance_id"
elif self._instance_id_exists(data[CONF_INSTANCE_ID]):
errors[CONF_INSTANCE_ID] = "instance_id_exists"
elif not errors:
await self.async_set_unique_id(data[CONF_CAMERA])
self._abort_if_unique_id_configured()
if not errors:
errors.update(await self._async_validate_backend(data))
if not errors:
name = data.pop(CONF_NAME)
return self.async_create_entry(title=name, data=data)
form_defaults = {**form_defaults, **data}
return self.async_show_form(
step_id="user",
data_schema=_schema(form_defaults, include_identity=True),
errors=errors,
)
def _defaults_from_existing_entry(self) -> dict[str, Any]:
"""Use the first existing detector to reduce repeated server entry."""
for entry in self._async_current_entries():
values = _entry_values(entry)
defaults = {
CONF_HOME_ASSISTANT_HOST: values.get(CONF_HOME_ASSISTANT_HOST),
CONF_OBICO_HOST: values.get(CONF_OBICO_HOST),
CONF_OBICO_AUTH_TOKEN: values.get(CONF_OBICO_AUTH_TOKEN),
CONF_DETECTION_INTERVAL: values.get(CONF_DETECTION_INTERVAL),
CONF_LIGHT_CONTROL_MODE: values.get(CONF_LIGHT_CONTROL_MODE),
CONF_LIGHT_SETTLE_SECONDS: values.get(CONF_LIGHT_SETTLE_SECONDS),
CONF_SENSITIVITY: values.get(CONF_SENSITIVITY),
CONF_WARNING_THRESHOLD: values.get(CONF_WARNING_THRESHOLD),
CONF_FAILURE_THRESHOLD: values.get(CONF_FAILURE_THRESHOLD),
CONF_COOLDOWN_SECONDS: values.get(CONF_COOLDOWN_SECONDS),
}
return {key: value for key, value in defaults.items() if value is not None}
return {CONF_HOME_ASSISTANT_HOST: self._home_assistant_url_default()}
def _home_assistant_url_default(self) -> str:
"""Return the best available HA URL for the ML server to fetch images."""
return (
getattr(self.hass.config, "internal_url", None)
or getattr(self.hass.config, "external_url", None)
or DEFAULT_HOME_ASSISTANT_HOST
)
def _instance_id_exists(self, instance_id: str) -> bool:
"""Return whether an entity prefix is already used."""
return any(
entry.data.get(CONF_INSTANCE_ID) == instance_id
for entry in self._async_current_entries()
)
def _camera_exists(self, camera: str) -> bool:
"""Return whether a camera is already used by another detector."""
return _camera_in_use(self._async_current_entries(), camera)
async def _async_validate_backend(self, data: dict[str, Any]) -> dict[str, str]:
"""Validate ML health and whether it can fetch the configured image."""
return await _async_validate_backend(self.hass, data)
class OptionsFlowHandler(config_entries.OptionsFlowWithReload):
"""Handle detector options."""
async def async_step_init(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Manage detector options."""
errors: dict[str, str] = {}
defaults = _entry_values(self.config_entry)
if user_input is not None:
data = dict(user_input)
errors.update(_validate_thresholds(data))
if _camera_in_use(
self.hass.config_entries.async_entries(DOMAIN),
data[CONF_CAMERA],
exclude_entry_id=self.config_entry.entry_id,
):
errors[CONF_CAMERA] = "already_configured"
if not errors:
errors.update(await _async_validate_backend(self.hass, data))
if not errors:
return self.async_create_entry(data=data)
defaults = {**defaults, **data}
return self.async_show_form(
step_id="init",
data_schema=_schema(defaults, include_identity=False),
errors=errors,
)
async def _async_validate_backend(hass, data: dict[str, Any]) -> dict[str, str]:
"""Return form errors for backend/camera connectivity problems."""
image_url = _build_image_url(hass, data)
if not image_url:
return {CONF_CAMERA: "camera_image_unavailable"}
session = async_get_clientsession(hass)
obico_host = data[CONF_OBICO_HOST].rstrip("/")
token = data[CONF_OBICO_AUTH_TOKEN]
headers = {"Authorization": f"Bearer {token}"}
try:
async with session.get(
f"{obico_host}/hc/",
timeout=aiohttp.ClientTimeout(total=10),
) as response:
if response.status >= 400:
return {CONF_OBICO_HOST: "ml_health_failed"}
except (aiohttp.ClientError, TimeoutError):
return {CONF_OBICO_HOST: "ml_health_failed"}
try:
async with session.get(
f"{obico_host}/debug/image",
params={"img": image_url},
headers=headers,
timeout=aiohttp.ClientTimeout(total=20),
) as response:
if response.status == 401:
return {CONF_OBICO_AUTH_TOKEN: "ml_auth_failed"}
if response.status >= 400:
return {CONF_CAMERA: "ml_image_fetch_failed"}
except (aiohttp.ClientError, TimeoutError):
return {CONF_CAMERA: "ml_image_fetch_failed"}
return {}
@@ -0,0 +1,86 @@
"""Constants for Elegoo spaghetti detection."""
from homeassistant.const import Platform
DOMAIN = "elegoo_spaghetti_detection"
BRAND = "Elegoo Spaghetti Detection"
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.BUTTON]
CONF_INSTANCE_ID = "instance_id"
CONF_HOME_ASSISTANT_HOST = "home_assistant_host"
CONF_OBICO_HOST = "obico_host"
CONF_OBICO_AUTH_TOKEN = "obico_auth_token"
CONF_CAMERA = "camera"
CONF_SNAPSHOT_URL = "snapshot_url"
CONF_PRINT_STATUS_SENSOR = "print_status_sensor"
CONF_ACTIVE_PRINT_STATES = "active_print_states"
CONF_CHAMBER_LIGHT = "chamber_light"
CONF_LIGHT_CONTROL_MODE = "light_control_mode"
CONF_LIGHT_SETTLE_SECONDS = "light_settle_seconds"
CONF_DETECTION_INTERVAL = "detection_interval"
CONF_RUN_WITHOUT_PRINTING = "run_without_printing"
CONF_FAILURE_THRESHOLD = "failure_threshold"
CONF_WARNING_THRESHOLD = "warning_threshold"
CONF_SENSITIVITY = "sensitivity"
CONF_COOLDOWN_SECONDS = "cooldown_seconds"
CONF_IMAGE_URL = "image_url"
CONF_CONFIG_ENTRY = "config_entry"
CONF_DETECTOR = "detector"
CONF_FORCE = "force"
DEFAULT_NAME = "Elegoo Spaghetti Detector"
DEFAULT_INSTANCE_ID = DOMAIN
DEFAULT_HOME_ASSISTANT_HOST = "http://homeassistant.local:8123"
DEFAULT_OBICO_HOST = "http://192.168.1.123:3333"
DEFAULT_OBICO_AUTH_TOKEN = "obico_api_secret"
DEFAULT_ACTIVE_PRINT_STATES = "printing"
DEFAULT_DETECTION_INTERVAL = 10
DEFAULT_COOLDOWN_SECONDS = 900
DEFAULT_FAILURE_THRESHOLD = 0.50
DEFAULT_WARNING_THRESHOLD = 0.30
DEFAULT_SENSITIVITY = "normal"
DEFAULT_LIGHT_CONTROL_MODE = "restore"
DEFAULT_LIGHT_SETTLE_SECONDS = 3
LIGHT_CONTROL_OFF = "off"
LIGHT_CONTROL_LEAVE_ON = "leave_on"
LIGHT_CONTROL_RESTORE = "restore"
REQUIRED_CONFIG_KEYS = frozenset(
{
CONF_INSTANCE_ID,
CONF_HOME_ASSISTANT_HOST,
CONF_OBICO_HOST,
CONF_OBICO_AUTH_TOKEN,
CONF_CAMERA,
}
)
SENSITIVITY_THRESHOLDS = {
"high": (0.20, 0.35),
"normal": (DEFAULT_WARNING_THRESHOLD, DEFAULT_FAILURE_THRESHOLD),
"low": (0.45, 0.70),
"custom": (DEFAULT_WARNING_THRESHOLD, DEFAULT_FAILURE_THRESHOLD),
}
EVENT_DETECTION_RESULT = f"{DOMAIN}_result"
EVENT_SPAGHETTI_DETECTED = f"{DOMAIN}_detected"
SERVICE_PREDICT = "predict"
SERVICE_RUN_DETECTION = "run_detection"
SERVICE_RESET_STATE = "reset_state"
RUNTIME_DATA = "runtime"
RUNTIME_BY_DETECTOR = "runtime_by_detector"
RUNTIME_ML_LOCK = "ml_lock"
ATTR_CONFIDENCE = "confidence"
ATTR_RAW_SCORE = "raw_score"
ATTR_DETECTED = "detected"
ATTR_DETECTIONS = "detections"
ATTR_IMAGE_URL = "image_url"
ATTR_LAST_ERROR = "last_error"
ATTR_LAST_RUN = "last_run"
ATTR_NEXT_RUN = "next_run"
ATTR_STATUS = "status"
@@ -0,0 +1,36 @@
"""Base entities for Elegoo spaghetti detection."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import DOMAIN
from .runtime import SpaghettiDetectorRuntime
class SpaghettiDetectorEntity(Entity):
"""Base entity for a detector runtime."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
entry: ConfigEntry,
runtime: SpaghettiDetectorRuntime,
key: str,
) -> None:
self.entry = entry
self.runtime = runtime
self._attr_unique_id = f"{entry.entry_id}_{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Elegoo",
model="Spaghetti detection",
name=entry.title,
)
async def async_added_to_hass(self) -> None:
"""Subscribe to runtime updates."""
self.async_on_remove(
self.runtime.async_add_listener(self.async_write_ha_state)
)
@@ -0,0 +1,16 @@
{
"domain": "elegoo_spaghetti_detection",
"name": "Elegoo Spaghetti Detection",
"codeowners": [
"@hepter"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/hepter/ha-elegoo-spaghetti-detection",
"integration_type": "hub",
"iot_class": "calculated",
"issue_tracker": "https://github.com/hepter/ha-elegoo-spaghetti-detection/issues",
"requirements": [],
"version": "1.0.0"
}
@@ -0,0 +1,583 @@
"""Runtime detection logic for Elegoo spaghetti detection."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import datetime, timedelta
import logging
from typing import Any
import aiohttp
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.util import dt as dt_util
from .const import (
ATTR_CONFIDENCE,
ATTR_DETECTED,
ATTR_DETECTIONS,
ATTR_IMAGE_URL,
ATTR_LAST_ERROR,
ATTR_LAST_RUN,
ATTR_NEXT_RUN,
ATTR_RAW_SCORE,
ATTR_STATUS,
CONF_ACTIVE_PRINT_STATES,
CONF_CAMERA,
CONF_CHAMBER_LIGHT,
CONF_COOLDOWN_SECONDS,
CONF_DETECTION_INTERVAL,
CONF_FAILURE_THRESHOLD,
CONF_HOME_ASSISTANT_HOST,
CONF_INSTANCE_ID,
CONF_LIGHT_CONTROL_MODE,
CONF_LIGHT_SETTLE_SECONDS,
CONF_OBICO_AUTH_TOKEN,
CONF_OBICO_HOST,
CONF_PRINT_STATUS_SENSOR,
CONF_RUN_WITHOUT_PRINTING,
CONF_SENSITIVITY,
CONF_SNAPSHOT_URL,
CONF_WARNING_THRESHOLD,
DEFAULT_ACTIVE_PRINT_STATES,
DEFAULT_COOLDOWN_SECONDS,
DEFAULT_DETECTION_INTERVAL,
DEFAULT_FAILURE_THRESHOLD,
DEFAULT_LIGHT_CONTROL_MODE,
DEFAULT_LIGHT_SETTLE_SECONDS,
DEFAULT_SENSITIVITY,
DEFAULT_WARNING_THRESHOLD,
DOMAIN,
EVENT_DETECTION_RESULT,
EVENT_SPAGHETTI_DETECTED,
LIGHT_CONTROL_LEAVE_ON,
LIGHT_CONTROL_OFF,
LIGHT_CONTROL_RESTORE,
RUNTIME_ML_LOCK,
SENSITIVITY_THRESHOLDS,
)
LOGGER = logging.getLogger(__name__)
def _parse_states(value: str | None) -> set[str]:
"""Parse a comma-separated list of states."""
if not value:
value = DEFAULT_ACTIVE_PRINT_STATES
return {item.strip().lower() for item in value.split(",") if item.strip()}
def _normalize_state(value: Any) -> str:
"""Normalize a Home Assistant state string for comparisons."""
return str(value).strip().lower()
def _score_detections(result: dict[str, Any]) -> tuple[float, int]:
"""Return a simple confidence score from the Obico detection payload."""
score = 0.0
detections = result.get("detections") or []
for detection in detections:
try:
score += float(detection[1])
except (TypeError, ValueError, IndexError):
continue
return min(1.0, max(0.0, score)), len(detections)
class SpaghettiDetectorRuntime:
"""Manage one camera/detector target."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self.entry = entry
self.data = {**entry.data, **entry.options}
self.detector_id: str = self.data[CONF_INSTANCE_ID]
self.name = entry.title
self.listeners: list[Callable[[], None]] = []
self.unsubscribers: list[CALLBACK_TYPE] = []
self.enabled = True
self.running = False
self.status = "idle"
self.printer_state: str | None = None
self.confidence = 0.0
self.raw_score = 0.0
self.detection_count = 0
self.detected = False
self.warning = False
self.last_run: datetime | None = None
self.last_detected: datetime | None = None
self.next_run: datetime | None = None
self.last_error: str | None = None
self.last_image_url: str | None = None
self.last_result: dict[str, Any] = {"detections": []}
self.lifetime_frames = 0
self.detected_event_sent_for_active_period = False
@property
def active_states(self) -> set[str]:
"""Return states that mean the printer is actively printing."""
return _parse_states(self.data.get(CONF_ACTIVE_PRINT_STATES))
@property
def warning_threshold(self) -> float:
"""Return warning threshold for this detector."""
sensitivity = self.data.get(CONF_SENSITIVITY, DEFAULT_SENSITIVITY)
default_warning, _ = SENSITIVITY_THRESHOLDS.get(
sensitivity,
SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY],
)
if sensitivity != "custom":
return float(default_warning)
return float(self.data.get(CONF_WARNING_THRESHOLD, default_warning))
@property
def failure_threshold(self) -> float:
"""Return failure threshold for this detector."""
sensitivity = self.data.get(CONF_SENSITIVITY, DEFAULT_SENSITIVITY)
_, default_failure = SENSITIVITY_THRESHOLDS.get(
sensitivity,
SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY],
)
if sensitivity != "custom":
return float(default_failure)
return float(self.data.get(CONF_FAILURE_THRESHOLD, default_failure))
@property
def cooldown(self) -> timedelta:
"""Return notification/action cooldown."""
return timedelta(
seconds=int(self.data.get(CONF_COOLDOWN_SECONDS, DEFAULT_COOLDOWN_SECONDS))
)
@property
def detection_interval(self) -> timedelta:
"""Return scheduled detection interval."""
return timedelta(
seconds=int(
self.data.get(CONF_DETECTION_INTERVAL, DEFAULT_DETECTION_INTERVAL)
)
)
@property
def light_control_mode(self) -> str:
"""Return how the detector should manage the configured light."""
mode = self.data.get(CONF_LIGHT_CONTROL_MODE, DEFAULT_LIGHT_CONTROL_MODE)
if mode in {LIGHT_CONTROL_OFF, LIGHT_CONTROL_LEAVE_ON, LIGHT_CONTROL_RESTORE}:
return mode
return DEFAULT_LIGHT_CONTROL_MODE
@property
def light_settle_seconds(self) -> int:
"""Return seconds to wait after turning on a light before snapshot."""
try:
seconds = int(
float(
self.data.get(
CONF_LIGHT_SETTLE_SECONDS,
DEFAULT_LIGHT_SETTLE_SECONDS,
)
)
)
except (TypeError, ValueError):
seconds = DEFAULT_LIGHT_SETTLE_SECONDS
return max(0, seconds)
async def async_setup(self) -> None:
"""Start scheduled detection."""
interval = self.detection_interval
self.next_run = dt_util.utcnow() + interval
self.unsubscribers.append(
async_track_time_interval(
self.hass,
self._async_interval_update,
interval,
)
)
if status_entity := self.data.get(CONF_PRINT_STATUS_SENSOR):
status_entities = [status_entity]
if guard_entity := self._inferred_guard_entity(status_entity):
status_entities.append(guard_entity)
self.unsubscribers.append(
async_track_state_change_event(
self.hass,
status_entities,
self._async_status_changed,
)
)
async def async_unload(self) -> None:
"""Stop scheduled detection."""
for unsubscribe in self.unsubscribers:
unsubscribe()
self.unsubscribers.clear()
self.listeners.clear()
@callback
def async_add_listener(self, listener: Callable[[], None]) -> CALLBACK_TYPE:
"""Add a listener for runtime state changes."""
self.listeners.append(listener)
@callback
def remove_listener() -> None:
self.listeners.remove(listener)
return remove_listener
@callback
def _notify_listeners(self) -> None:
"""Notify entities that runtime state changed."""
for listener in list(self.listeners):
listener()
async def _async_interval_update(self, now: datetime) -> None:
"""Run detection on interval if the target is active."""
self.next_run = now + self.detection_interval
self._notify_listeners()
if self.enabled and self._should_run_scheduled():
await self.async_run_detection(manual=False)
@callback
def _async_status_changed(self, event) -> None:
"""Reset state when a new print starts."""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if new_state is None:
return
was_active = (
old_state is not None
and _normalize_state(old_state.state) in self.active_states
)
is_active = _normalize_state(new_state.state) in self.active_states
if was_active and not is_active:
self.detected_event_sent_for_active_period = False
if is_active and not was_active:
self.reset()
def _should_run_scheduled(self) -> bool:
"""Return if scheduled detection should run."""
status_entity = self.data.get(CONF_PRINT_STATUS_SENSOR)
if not status_entity:
self.printer_state = None
if bool(self.data.get(CONF_RUN_WITHOUT_PRINTING, False)):
return True
self.status = "waiting_for_print"
self._notify_listeners()
return False
state = self.hass.states.get(status_entity)
if state is None:
self.status = "status_unavailable"
self.printer_state = None
self._notify_listeners()
return False
self.printer_state = str(state.state)
normalized_state = _normalize_state(state.state)
if normalized_state not in self.active_states:
self.status = (
"status_unavailable"
if normalized_state in {"unknown", "unavailable"}
else "waiting_for_print"
)
self._notify_listeners()
return False
if not self._passes_inferred_guard_sensor(status_entity):
if self.status != "status_unavailable":
self.status = "waiting_for_print"
self._notify_listeners()
return False
self._notify_listeners()
return True
def _passes_inferred_guard_sensor(self, status_entity: str) -> bool:
"""Return false when an inferred companion status says not active."""
guard_entity = self._inferred_guard_entity(status_entity)
if guard_entity is None:
return True
state = self.hass.states.get(guard_entity)
if state is None:
return True
self.printer_state = f"{self.printer_state}; {guard_entity}={state.state}"
normalized_state = _normalize_state(state.state)
if normalized_state in {"unknown", "unavailable"}:
self.status = "status_unavailable"
return False
return normalized_state in self.active_states
def _inferred_guard_entity(self, status_entity: str) -> str | None:
"""Infer an Elegoo companion current-status sensor when available."""
suffix = "_print_status"
if not status_entity.endswith(suffix):
return None
candidate = f"{status_entity[: -len(suffix)]}_current_status"
if candidate == status_entity:
return None
return candidate
def reset(self) -> None:
"""Reset detection state."""
self.status = "idle"
self.confidence = 0.0
self.raw_score = 0.0
self.detection_count = 0
self.detected = False
self.warning = False
self.last_detected = None
self.last_error = None
self.last_result = {"detections": []}
self.lifetime_frames = 0
self.detected_event_sent_for_active_period = False
self._notify_listeners()
async def async_run_detection(self, *, manual: bool) -> dict[str, Any]:
"""Run one detection request."""
if self.running:
self.status = "busy"
self._notify_listeners()
return self._service_result()
if not manual and not self._should_run_scheduled():
return self._service_result()
self.running = True
self.status = "checking"
self.last_run = dt_util.utcnow()
self.last_error = None
self._notify_listeners()
restore_light: str | None = None
try:
restore_light = await self._async_prepare_light()
if not manual and not self._should_run_scheduled():
return self._service_result()
image_url = self._build_image_url()
if not image_url:
self._set_error("camera_image_unavailable")
return self._service_result()
self.last_image_url = image_url
try:
ml_lock = self.hass.data[DOMAIN][RUNTIME_ML_LOCK]
async with ml_lock:
result = await self._async_predict(image_url)
except (aiohttp.ClientError, TimeoutError) as err:
self._set_error(str(err))
LOGGER.warning(
"Obico ML request failed for %s: %s",
self.detector_id,
err,
)
return self._service_result()
self.last_result = result
self.raw_score, self.detection_count = _score_detections(result)
self.confidence = self.raw_score
self.warning = self.confidence >= self.warning_threshold
self.detected = self.confidence >= self.failure_threshold
self.status = (
"detected" if self.detected else "warning" if self.warning else "clear"
)
self.lifetime_frames += 1
self._fire_result_event(manual)
if self.detected and self._can_fire_detected_event(manual):
self.last_detected = dt_util.utcnow()
if not manual and self.data.get(CONF_PRINT_STATUS_SENSOR):
self.detected_event_sent_for_active_period = True
self._fire_detected_event(manual)
self._notify_listeners()
return self._service_result()
finally:
if restore_light is not None:
await self._async_restore_light(restore_light)
self.running = False
async def _async_predict(self, image_url: str) -> dict[str, Any]:
"""Call the Obico ML API."""
session = async_get_clientsession(self.hass)
async with session.get(
f"{self.data[CONF_OBICO_HOST].rstrip('/')}/p/",
params={"img": image_url},
headers={"Authorization": f"Bearer {self.data[CONF_OBICO_AUTH_TOKEN]}"},
timeout=aiohttp.ClientTimeout(total=60),
) as response:
if response.status >= 400:
error_message = await _response_error_message(response)
raise aiohttp.ClientResponseError(
response.request_info,
response.history,
status=response.status,
message=error_message,
headers=response.headers,
)
response.raise_for_status()
result = await response.json()
if not isinstance(result, dict):
return {"detections": []}
return result
async def _async_prepare_light(self) -> str | None:
"""Prepare the configured light before detection.
Returns the entity ID to restore when the integration turned an off
light on and the selected mode wants the previous state restored.
"""
if self.light_control_mode == LIGHT_CONTROL_OFF:
return None
light_entity = self.data.get(CONF_CHAMBER_LIGHT)
if not light_entity:
return None
state = self.hass.states.get(light_entity)
if state is None or _normalize_state(state.state) != "off":
return None
try:
await self.hass.services.async_call(
"light",
"turn_on",
{"entity_id": light_entity},
blocking=True,
)
except HomeAssistantError as err:
LOGGER.warning("Could not turn on light %s: %s", light_entity, err)
return None
restore_light = (
light_entity if self.light_control_mode == LIGHT_CONTROL_RESTORE else None
)
try:
if self.light_settle_seconds:
await asyncio.sleep(self.light_settle_seconds)
except asyncio.CancelledError:
if restore_light is not None:
await self._async_restore_light(restore_light)
raise
return restore_light
async def _async_restore_light(self, light_entity: str) -> None:
"""Restore a light that the detector temporarily turned on."""
state = self.hass.states.get(light_entity)
if state is not None and _normalize_state(state.state) == "off":
return
try:
await self.hass.services.async_call(
"light",
"turn_off",
{"entity_id": light_entity},
blocking=True,
)
except HomeAssistantError as err:
LOGGER.warning("Could not restore light %s: %s", light_entity, err)
def _build_image_url(self) -> str | None:
"""Build a snapshot URL for the configured camera."""
if snapshot_url := self.data.get(CONF_SNAPSHOT_URL):
return snapshot_url
camera_entity = self.data.get(CONF_CAMERA)
state = self.hass.states.get(camera_entity)
if state is None:
return None
entity_picture = state.attributes.get("entity_picture")
if not entity_picture:
return None
return f"{self.data[CONF_HOME_ASSISTANT_HOST].rstrip('/')}{entity_picture}"
def _cooldown_elapsed(self) -> bool:
"""Return whether a detected event can be fired."""
if self.last_detected is None:
return True
return dt_util.utcnow() - self.last_detected >= self.cooldown
def _can_fire_detected_event(self, manual: bool) -> bool:
"""Return whether the detected event should be emitted."""
if not manual and self.data.get(CONF_PRINT_STATUS_SENSOR):
return not self.detected_event_sent_for_active_period
return self._cooldown_elapsed()
def _event_data(self, manual: bool) -> dict[str, Any]:
"""Return event payload."""
return {
"config_entry": self.entry.entry_id,
"detector": self.detector_id,
"name": self.name,
"camera": self.data.get(CONF_CAMERA),
"manual": manual,
"printer_state": self.printer_state,
ATTR_CONFIDENCE: self.confidence,
ATTR_RAW_SCORE: self.raw_score,
ATTR_DETECTED: self.detected,
ATTR_DETECTIONS: self.detection_count,
ATTR_IMAGE_URL: self.last_image_url,
ATTR_LAST_ERROR: self.last_error,
ATTR_LAST_RUN: self.last_run.isoformat() if self.last_run else None,
ATTR_NEXT_RUN: self.next_run.isoformat() if self.next_run else None,
ATTR_STATUS: self.status,
}
def _fire_result_event(self, manual: bool) -> None:
"""Fire an event for every detection result."""
self.hass.bus.async_fire(EVENT_DETECTION_RESULT, self._event_data(manual))
def _fire_detected_event(self, manual: bool) -> None:
"""Fire an event when spaghetti is detected."""
self.hass.bus.async_fire(EVENT_SPAGHETTI_DETECTED, self._event_data(manual))
def _service_result(self) -> dict[str, Any]:
"""Return service response payload."""
return {
"result": self.last_result,
ATTR_CONFIDENCE: self.confidence,
ATTR_RAW_SCORE: self.raw_score,
ATTR_DETECTED: self.detected,
ATTR_DETECTIONS: self.detection_count,
ATTR_IMAGE_URL: self.last_image_url,
ATTR_LAST_ERROR: self.last_error,
ATTR_NEXT_RUN: self.next_run.isoformat() if self.next_run else None,
ATTR_STATUS: self.status,
}
def _set_error(self, error: str) -> None:
"""Set a runtime error and notify listeners."""
self.status = "error"
self.last_error = error
self.detected = False
self.warning = False
self.confidence = 0.0
self.raw_score = 0.0
self.detection_count = 0
self._notify_listeners()
async def _response_error_message(response: aiohttp.ClientResponse) -> str:
"""Return a useful error message from an ML server error response."""
try:
payload = await response.json()
except (aiohttp.ContentTypeError, ValueError):
return await response.text()
if not isinstance(payload, dict):
return str(payload)
if error := payload.get("error"):
message = payload.get("message")
return f"{error}: {message}" if message else str(error)
return str(payload)
@@ -0,0 +1,106 @@
"""Sensors for Elegoo spaghetti detection."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from .const import CONF_INSTANCE_ID, DOMAIN, RUNTIME_DATA
from .entity import SpaghettiDetectorEntity
@dataclass(frozen=True, kw_only=True)
class DetectorSensorDescription(SensorEntityDescription):
"""Detector sensor description."""
value_fn: Any
SENSORS: tuple[DetectorSensorDescription, ...] = (
DetectorSensorDescription(
key="confidence",
name="Confidence",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda runtime: round(runtime.confidence * 100, 1),
),
DetectorSensorDescription(
key="raw_score",
name="Raw Score",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda runtime: round(runtime.raw_score, 4),
),
DetectorSensorDescription(
key="detections",
name="Detection Count",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda runtime: runtime.detection_count,
),
DetectorSensorDescription(
key="status",
name="Status",
value_fn=lambda runtime: runtime.status,
),
DetectorSensorDescription(
key="last_error",
name="Last Error",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda runtime: runtime.last_error or "none",
),
DetectorSensorDescription(
key="last_run",
name="Last Run",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda runtime: runtime.last_run,
),
DetectorSensorDescription(
key="next_run",
name="Next Run",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda runtime: runtime.next_run,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities,
) -> None:
"""Set up sensors."""
runtime = hass.data[DOMAIN][RUNTIME_DATA][entry.entry_id]
async_add_entities(
DetectorSensor(entry, runtime, description) for description in SENSORS
)
class DetectorSensor(SpaghettiDetectorEntity, SensorEntity):
"""Detector sensor."""
entity_description: DetectorSensorDescription
def __init__(
self,
entry: ConfigEntry,
runtime,
description: DetectorSensorDescription,
) -> None:
super().__init__(entry, runtime, description.key)
self.entity_description = description
self.entity_id = f"sensor.{entry.data[CONF_INSTANCE_ID]}_{description.key}"
@property
def native_value(self):
"""Return the current sensor value."""
return self.entity_description.value_fn(self.runtime)
@@ -0,0 +1,60 @@
predict:
name: "Predict spaghetti from URL"
description: "Runs the Obico ML model against a raw image URL. This is mainly for debugging."
fields:
obico_host:
description: "Obico ML Server URL."
example: "http://192.168.1.123:3333"
required: true
selector:
text:
obico_auth_token:
description: "Obico ML Server authentication token."
example: "obico_api_secret"
required: true
selector:
text:
image_url:
description: "Snapshot URL to check."
example: "https://home.example.com/api/camera_proxy/camera.example?token=..."
required: true
selector:
text:
run_detection:
name: "Run detection"
description: "Runs one detection check for a configured detector. With force enabled this works even when the printer is not printing."
fields:
detector:
description: "Detector/entity prefix, for example elegoo_spaghetti_detection."
required: false
selector:
text:
config_entry:
description: "Detector config entry."
required: false
selector:
config_entry:
integration: elegoo_spaghetti_detection
force:
description: "Run as a manual test and bypass the print-status gate."
required: false
default: true
selector:
boolean:
reset_state:
name: "Reset detection state"
description: "Clears the current detector confidence, result, and error state."
fields:
detector:
description: "Detector/entity prefix."
required: false
selector:
text:
config_entry:
description: "Detector config entry."
required: false
selector:
config_entry:
integration: elegoo_spaghetti_detection
@@ -0,0 +1,150 @@
{
"config": {
"step": {
"user": {
"title": "Elegoo Spaghetti Detection",
"description": "ينشئ كاشفا لكاميرا طابعة Elegoo. هذا التكامل يكتشف الاخطاء فقط وينشئ entities/events؛ تبقى اجراءات pause و stop والتنبيهات داخل automations الخاصة بك.",
"data": {
"name": "اسم الكاشف",
"instance_id": "بادئة entity",
"home_assistant_host": "Home Assistant Host",
"obico_host": "Obico ML API Host",
"obico_auth_token": "Obico ML API Auth Token",
"camera": "الكاميرا",
"snapshot_url": "رابط snapshot مباشر",
"print_status_sensor": "حساس حالة الطباعة",
"active_print_states": "حالات الطباعة النشطة",
"chamber_light": "ضوء الحجرة",
"light_control_mode": "التحكم بالضوء",
"light_settle_seconds": "تأخير استقرار الضوء",
"run_without_printing": "تشغيل الكشف المجدول بدون حالة طباعة",
"detection_interval": "فاصل الكشف",
"sensitivity": "الحساسية",
"warning_threshold": "حد التحذير",
"failure_threshold": "حد الفشل",
"cooldown_seconds": "فترة تهدئة حدث detected"
},
"data_description": {
"home_assistant_host": "URL يمكن لخادم ML الوصول اليه. عند تشغيل Docker على مضيف LAN اخر، استخدم HA LAN URL مثل http://192.168.1.90:8123.",
"obico_host": "Base URL لخادم ML الخاص بهذا المشروع، مثل http://192.168.1.100:3333. يقوم الاعداد بفحص /hc/ و /debug/image.",
"obico_auth_token": "يجب ان يطابق ML_API_TOKEN / obico_api_secret المكون على خادم ML.",
"instance_id": "Slug ثابت يستخدم في entity IDs. استخدم بادئة مختلفة لكل كاشف، مثل elegoo_cc2_left.",
"camera": "اي HA camera entity. مثال من elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. قد يختلف اسم جهازك.",
"snapshot_url": "اختياري. اتركه فارغا لاستخدام صورة Home Assistant camera proxy للكاميرا المحددة. استخدمه فقط للكاميرات غير المعتادة.",
"print_status_sensor": "اختياري لكن موصى به. مثال: sensor.elegoo_centauri_carbon2_print_status. للاسماء بأسلوب Elegoo، يتم استخدام حساس current_status المطابق تلقائيا كحارس اضافي.",
"active_print_states": "حالات مفصولة بفواصل تعني الطباعة، مثل printing,printing_recovery. تستخدم Elegoo CC2 عادة printing.",
"chamber_light": "اختياري. مثال: light.elegoo_centauri_carbon2_chamber_light. يستخدم فقط بواسطة اعداد التحكم بالضوء.",
"light_control_mode": "اختر ما اذا كان الكشف لا يتحكم بالضوء، او يشغله ويبقيه مشغلا، او يعيد حالة الضوء السابقة بعد كل snapshot.",
"light_settle_seconds": "عدد الثواني للانتظار بعد ان يشغل التكامل ضوءا كان مطفأ قبل اخذ snapshot. الافتراضي 3 ثوان للتعريض/التركيز.",
"run_without_printing": "اذا لم يتم تحديد حساس حالة طباعة، يعمل الكشف المجدول فقط عند تفعيل هذا الخيار. زر Test يشغل فحصا واحدا دائما.",
"detection_interval": "عدد الثواني بين الفحوصات المجدولة عندما تكون حالة الطباعة نشطة. امثلة: 600 لعشر دقائق، 900 لخمس عشرة دقيقة.",
"warning_threshold": "يستخدم عندما تكون الحساسية Custom thresholds.",
"failure_threshold": "يستخدم عندما تكون الحساسية Custom thresholds.",
"cooldown_seconds": "الافتراضي 900 ثانية. مع حساس حالة طباعة، ترسل الفحوصات المجدولة حدث detected واحدا لكل نافذة طباعة نشطة؛ وتستمر result events في الارسال مع كل فحص."
}
}
},
"error": {
"invalid_instance_id": "يجب ان تحتوي بادئة entity على حرف slug صالح واحد على الاقل.",
"instance_id_exists": "بادئة entity هذه مستخدمة بالفعل بواسطة كاشف اخر.",
"warning_above_failure": "يجب ان يكون حد التحذير اقل من حد الفشل او مساويا له.",
"already_configured": "هذه الكاميرا مكونة بالفعل.",
"camera_image_unavailable": "الكاميرا المحددة لا تعرض entity_picture URL. جرب كاميرا اخرى او اضبط snapshot URL مباشر.",
"ml_health_failed": "فشل فحص صحة خادم ML. تأكد ان المضيف قابل للوصول ويشير الى base URL مثل http://192.168.1.100:3333.",
"ml_auth_failed": "رفض خادم ML الرمز.",
"ml_image_fetch_failed": "تعذر على خادم ML جلب صورة الكاميرا او فك ترميزها. تحقق من Home Assistant Host ووصول الكاميرا من خادم ML."
},
"abort": {
"already_configured": "هذه الكاميرا مكونة بالفعل."
}
},
"options": {
"step": {
"init": {
"title": "اعدادات الكاشف",
"description": "حدث اعدادات الكاميرا وخادم ML وحالة الطباعة والكشف لهذا الكاشف.",
"data": {
"home_assistant_host": "Home Assistant Host",
"obico_host": "Obico ML API Host",
"obico_auth_token": "Obico ML API Auth Token",
"camera": "الكاميرا",
"snapshot_url": "رابط snapshot مباشر",
"print_status_sensor": "حساس حالة الطباعة",
"active_print_states": "حالات الطباعة النشطة",
"chamber_light": "ضوء الحجرة",
"light_control_mode": "التحكم بالضوء",
"light_settle_seconds": "تأخير استقرار الضوء",
"run_without_printing": "تشغيل الكشف المجدول بدون حالة طباعة",
"detection_interval": "فاصل الكشف",
"sensitivity": "الحساسية",
"warning_threshold": "حد التحذير",
"failure_threshold": "حد الفشل",
"cooldown_seconds": "فترة تهدئة حدث detected"
},
"data_description": {
"home_assistant_host": "URL يمكن لخادم ML الوصول اليه. عند تشغيل Docker على مضيف LAN اخر، استخدم HA LAN URL مثل http://192.168.1.90:8123.",
"obico_host": "Base URL لخادم ML الخاص بهذا المشروع، مثل http://192.168.1.100:3333. يقوم الاعداد بفحص /hc/ و /debug/image.",
"obico_auth_token": "يجب ان يطابق ML_API_TOKEN / obico_api_secret المكون على خادم ML.",
"camera": "اي HA camera entity. مثال من elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. قد يختلف اسم جهازك.",
"snapshot_url": "اختياري. اتركه فارغا لاستخدام صورة Home Assistant camera proxy للكاميرا المحددة.",
"print_status_sensor": "اختياري لكن موصى به. مثال: sensor.elegoo_centauri_carbon2_print_status. للاسماء بأسلوب Elegoo، يتم استخدام حساس current_status المطابق تلقائيا كحارس اضافي.",
"active_print_states": "حالات مفصولة بفواصل تعني الطباعة، مثل printing,printing_recovery.",
"chamber_light": "اختياري. مثال: light.elegoo_centauri_carbon2_chamber_light. يستخدم فقط بواسطة اعداد التحكم بالضوء.",
"light_control_mode": "اختر ما اذا كان الكشف لا يتحكم بالضوء، او يشغله ويبقيه مشغلا، او يعيد حالة الضوء السابقة بعد كل snapshot.",
"light_settle_seconds": "عدد الثواني للانتظار بعد ان يشغل التكامل ضوءا كان مطفأ قبل اخذ snapshot. الافتراضي 3 ثوان للتعريض/التركيز.",
"run_without_printing": "اذا لم يتم تحديد حساس حالة طباعة، يعمل الكشف المجدول فقط عند تفعيل هذا الخيار. زر Test يشغل فحصا واحدا دائما.",
"detection_interval": "عدد الثواني بين الفحوصات المجدولة عندما تكون حالة الطباعة نشطة. امثلة: 600 لعشر دقائق، 900 لخمس عشرة دقيقة.",
"warning_threshold": "يستخدم عندما تكون الحساسية Custom thresholds.",
"failure_threshold": "يستخدم عندما تكون الحساسية Custom thresholds.",
"cooldown_seconds": "الافتراضي 900 ثانية. مع حساس حالة طباعة، ترسل الفحوصات المجدولة حدث detected واحدا لكل نافذة طباعة نشطة؛ وتستمر result events في الارسال مع كل فحص."
}
}
},
"error": {
"warning_above_failure": "يجب ان يكون حد التحذير اقل من حد الفشل او مساويا له.",
"camera_image_unavailable": "الكاميرا المحددة لا تعرض entity_picture URL. جرب كاميرا اخرى او اضبط snapshot URL مباشر.",
"ml_health_failed": "فشل فحص صحة خادم ML. تأكد ان المضيف قابل للوصول ويشير الى base URL مثل http://192.168.1.100:3333.",
"ml_auth_failed": "رفض خادم ML الرمز.",
"ml_image_fetch_failed": "تعذر على خادم ML جلب صورة الكاميرا او فك ترميزها. تحقق من Home Assistant Host ووصول الكاميرا من خادم ML."
}
},
"services": {
"predict": {
"name": "توقع spaghetti من URL",
"description": "يشغل نموذج Obico ML على URL صورة خام",
"fields": {
"obico_host": {
"name": "Obico ML API Host",
"description": "Obico ML API host"
},
"obico_auth_token": {
"name": "Obico ML API Auth Token",
"description": "رمز مصادقة Obico ML API"
},
"image_url": {
"name": "Image URL",
"description": "Snapshot URL"
}
}
},
"run_detection": {
"name": "تشغيل الكشف",
"description": "يشغل فحص كشف واحدا لكاشف مكون.",
"fields": {
"detector": {
"name": "الكاشف"
},
"config_entry": {
"name": "Config entry"
},
"force": {
"name": "اجبار"
}
}
},
"reset_state": {
"name": "اعادة ضبط حالة الكشف",
"description": "يمسح confidence والنتيجة وحالة الخطأ للكاشف."
}
}
}
@@ -0,0 +1,150 @@
{
"config": {
"step": {
"user": {
"title": "Elegoo Spaghetti Detection",
"description": "Create one detector for an Elegoo printer camera. The integration only detects failures and fires entities/events; pause, stop, and notify actions stay in your own automations.",
"data": {
"name": "Detector name",
"instance_id": "Entity prefix",
"home_assistant_host": "Home Assistant Host",
"obico_host": "Obico ML API Host",
"obico_auth_token": "Obico ML API Auth Token",
"camera": "Camera",
"snapshot_url": "Direct snapshot URL",
"print_status_sensor": "Print status sensor",
"active_print_states": "Active print states",
"chamber_light": "Chamber light",
"light_control_mode": "Light control",
"light_settle_seconds": "Light settle delay",
"run_without_printing": "Run scheduled detection without print status",
"detection_interval": "Detection interval",
"sensitivity": "Sensitivity",
"warning_threshold": "Warning threshold",
"failure_threshold": "Failure threshold",
"cooldown_seconds": "Detected event cooldown"
},
"data_description": {
"home_assistant_host": "URL reachable by the ML server. For Docker on another LAN host, use the HA LAN URL, for example http://192.168.1.90:8123.",
"obico_host": "Base URL of this project's ML server, for example http://192.168.1.100:3333. The setup checks /hc/ and /debug/image.",
"obico_auth_token": "Must match ML_API_TOKEN / obico_api_secret configured on the ML server.",
"instance_id": "Stable slug used in entity IDs. Use a different prefix for each detector, for example elegoo_cc2_left.",
"camera": "Any HA camera entity. Example from elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. Your device name may differ.",
"snapshot_url": "Optional. Leave empty to use the selected camera entity's Home Assistant camera proxy image. Use this only for unusual cameras.",
"print_status_sensor": "Optional but recommended. Example: sensor.elegoo_centauri_carbon2_print_status. For Elegoo-style names, the matching current_status sensor is used automatically as an extra guard.",
"active_print_states": "Comma-separated states that mean printing, for example printing,printing_recovery. Elegoo CC2 usually uses printing.",
"chamber_light": "Optional. Example: light.elegoo_centauri_carbon2_chamber_light. Used only by the light-control setting.",
"light_control_mode": "Choose whether detection should leave the light alone, turn it on and leave it on, or restore the previous light state after each snapshot.",
"light_settle_seconds": "Seconds to wait after this integration turns on an off light before taking the snapshot. Default is 3 seconds for camera exposure/focus.",
"run_without_printing": "If no print status sensor is selected, scheduled detection only runs when this is enabled. The Test button always runs one check.",
"detection_interval": "Seconds between scheduled checks while the print status is active. Examples: 600 for 10 minutes, 900 for 15 minutes.",
"warning_threshold": "Used when Sensitivity is set to Custom thresholds.",
"failure_threshold": "Used when Sensitivity is set to Custom thresholds.",
"cooldown_seconds": "Default is 900 seconds. Scheduled checks with a print status sensor emit one detected event per active print window; result events still fire for every check."
}
}
},
"error": {
"invalid_instance_id": "Entity prefix must contain at least one valid slug character.",
"instance_id_exists": "This entity prefix is already used by another detector.",
"warning_above_failure": "Warning threshold must be lower than or equal to failure threshold.",
"already_configured": "This camera is already configured.",
"camera_image_unavailable": "The selected camera does not expose an entity_picture URL. Try another camera or set a direct snapshot URL.",
"ml_health_failed": "The ML server health check failed. Confirm the host is reachable and points to the base URL, for example http://192.168.1.100:3333.",
"ml_auth_failed": "The ML server rejected the token.",
"ml_image_fetch_failed": "The ML server could not fetch or decode the camera image. Check Home Assistant Host and camera access from the ML server."
},
"abort": {
"already_configured": "This camera is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Detector settings",
"description": "Update camera, ML server, print-state, and detection settings for this detector.",
"data": {
"home_assistant_host": "Home Assistant Host",
"obico_host": "Obico ML API Host",
"obico_auth_token": "Obico ML API Auth Token",
"camera": "Camera",
"snapshot_url": "Direct snapshot URL",
"print_status_sensor": "Print status sensor",
"active_print_states": "Active print states",
"chamber_light": "Chamber light",
"light_control_mode": "Light control",
"light_settle_seconds": "Light settle delay",
"run_without_printing": "Run scheduled detection without print status",
"detection_interval": "Detection interval",
"sensitivity": "Sensitivity",
"warning_threshold": "Warning threshold",
"failure_threshold": "Failure threshold",
"cooldown_seconds": "Detected event cooldown"
},
"data_description": {
"home_assistant_host": "URL reachable by the ML server. For Docker on another LAN host, use the HA LAN URL, for example http://192.168.1.90:8123.",
"obico_host": "Base URL of this project's ML server, for example http://192.168.1.100:3333. The setup checks /hc/ and /debug/image.",
"obico_auth_token": "Must match ML_API_TOKEN / obico_api_secret configured on the ML server.",
"camera": "Any HA camera entity. Example from elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. Your device name may differ.",
"snapshot_url": "Optional. Leave empty to use the selected camera entity's Home Assistant camera proxy image.",
"print_status_sensor": "Optional but recommended. Example: sensor.elegoo_centauri_carbon2_print_status. For Elegoo-style names, the matching current_status sensor is used automatically as an extra guard.",
"active_print_states": "Comma-separated states that mean printing, for example printing,printing_recovery.",
"chamber_light": "Optional. Example: light.elegoo_centauri_carbon2_chamber_light. Used only by the light-control setting.",
"light_control_mode": "Choose whether detection should leave the light alone, turn it on and leave it on, or restore the previous light state after each snapshot.",
"light_settle_seconds": "Seconds to wait after this integration turns on an off light before taking the snapshot. Default is 3 seconds for camera exposure/focus.",
"run_without_printing": "If no print status sensor is selected, scheduled detection only runs when this is enabled. The Test button always runs one check.",
"detection_interval": "Seconds between scheduled checks while the print status is active. Examples: 600 for 10 minutes, 900 for 15 minutes.",
"warning_threshold": "Used when Sensitivity is set to Custom thresholds.",
"failure_threshold": "Used when Sensitivity is set to Custom thresholds.",
"cooldown_seconds": "Default is 900 seconds. Scheduled checks with a print status sensor emit one detected event per active print window; result events still fire for every check."
}
}
},
"error": {
"warning_above_failure": "Warning threshold must be lower than or equal to failure threshold.",
"camera_image_unavailable": "The selected camera does not expose an entity_picture URL. Try another camera or set a direct snapshot URL.",
"ml_health_failed": "The ML server health check failed. Confirm the host is reachable and points to the base URL, for example http://192.168.1.100:3333.",
"ml_auth_failed": "The ML server rejected the token.",
"ml_image_fetch_failed": "The ML server could not fetch or decode the camera image. Check Home Assistant Host and camera access from the ML server."
}
},
"services": {
"predict": {
"name": "Predict spaghetti from URL",
"description": "Runs the Obico ML model against a raw image URL",
"fields": {
"obico_host": {
"name": "Obico ML API Host",
"description": "Obico ML API host"
},
"obico_auth_token": {
"name": "Obico ML API Auth Token",
"description": "Obico ML API authentication token"
},
"image_url": {
"name": "Image URL",
"description": "Snapshot URL"
}
}
},
"run_detection": {
"name": "Run detection",
"description": "Runs one detection check for a configured detector.",
"fields": {
"detector": {
"name": "Detector"
},
"config_entry": {
"name": "Config entry"
},
"force": {
"name": "Force"
}
}
},
"reset_state": {
"name": "Reset detection state",
"description": "Clears the detector confidence, result, and error state."
}
}
}
@@ -0,0 +1,150 @@
{
"config": {
"step": {
"user": {
"title": "Elegoo Spaghetti Detection",
"description": "Crea un detector para una camara de impresora Elegoo. La integracion solo detecta fallos y genera entidades/eventos; las acciones de pausar, detener y notificar quedan en tus automatizaciones.",
"data": {
"name": "Nombre del detector",
"instance_id": "Prefijo de entidad",
"home_assistant_host": "Host de Home Assistant",
"obico_host": "Host de la API ML de Obico",
"obico_auth_token": "Token de API ML de Obico",
"camera": "Camara",
"snapshot_url": "URL directa de captura",
"print_status_sensor": "Sensor de estado de impresion",
"active_print_states": "Estados activos de impresion",
"chamber_light": "Luz de camara",
"light_control_mode": "Control de luz",
"light_settle_seconds": "Espera de luz",
"run_without_printing": "Ejecutar deteccion programada sin estado de impresion",
"detection_interval": "Intervalo de deteccion",
"sensitivity": "Sensibilidad",
"warning_threshold": "Umbral de advertencia",
"failure_threshold": "Umbral de fallo",
"cooldown_seconds": "Enfriamiento del evento detectado"
},
"data_description": {
"home_assistant_host": "URL accesible por el servidor ML. Para Docker en otro host LAN, usa la URL LAN de HA, por ejemplo http://192.168.1.90:8123.",
"obico_host": "URL base del servidor ML de este proyecto, por ejemplo http://192.168.1.100:3333. La configuracion comprueba /hc/ y /debug/image.",
"obico_auth_token": "Debe coincidir con ML_API_TOKEN / obico_api_secret configurado en el servidor ML.",
"instance_id": "Slug estable usado en los ID de entidad. Usa un prefijo distinto para cada detector, por ejemplo elegoo_cc2_left.",
"camera": "Cualquier entidad de camara de HA. Ejemplo de elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. El nombre de tu dispositivo puede variar.",
"snapshot_url": "Opcional. Dejalo vacio para usar la imagen proxy de la camara seleccionada en Home Assistant. Usalo solo para camaras poco comunes.",
"print_status_sensor": "Opcional pero recomendado. Ejemplo: sensor.elegoo_centauri_carbon2_print_status. Para nombres estilo Elegoo, el sensor current_status coincidente se usa automaticamente como proteccion extra.",
"active_print_states": "Estados separados por comas que significan impresion, por ejemplo printing,printing_recovery. Elegoo CC2 normalmente usa printing.",
"chamber_light": "Opcional. Ejemplo: light.elegoo_centauri_carbon2_chamber_light. Solo lo usa el ajuste de control de luz.",
"light_control_mode": "Elige si la deteccion no controla la luz, la enciende y la deja encendida, o restaura el estado anterior despues de cada captura.",
"light_settle_seconds": "Segundos que se esperan despues de encender una luz apagada antes de tomar la captura. El valor predeterminado es 3 segundos para exposicion/enfoque.",
"run_without_printing": "Si no se selecciona sensor de estado, la deteccion programada solo se ejecuta cuando esto esta activado. El boton Test siempre ejecuta una comprobacion.",
"detection_interval": "Segundos entre comprobaciones programadas mientras el estado de impresion esta activo. Ejemplos: 600 para 10 minutos, 900 para 15 minutos.",
"warning_threshold": "Se usa cuando Sensibilidad esta en Custom thresholds.",
"failure_threshold": "Se usa cuando Sensibilidad esta en Custom thresholds.",
"cooldown_seconds": "El valor predeterminado es 900 segundos. Con sensor de estado, las comprobaciones programadas emiten un evento detected por ventana activa de impresion; los eventos result siguen emitiendose en cada comprobacion."
}
}
},
"error": {
"invalid_instance_id": "El prefijo de entidad debe contener al menos un caracter slug valido.",
"instance_id_exists": "Este prefijo de entidad ya lo usa otro detector.",
"warning_above_failure": "El umbral de advertencia debe ser menor o igual que el umbral de fallo.",
"already_configured": "Esta camara ya esta configurada.",
"camera_image_unavailable": "La camara seleccionada no expone una URL entity_picture. Prueba otra camara o define una URL directa de captura.",
"ml_health_failed": "La comprobacion de salud del servidor ML fallo. Confirma que el host sea accesible y apunte a la URL base, por ejemplo http://192.168.1.100:3333.",
"ml_auth_failed": "El servidor ML rechazo el token.",
"ml_image_fetch_failed": "El servidor ML no pudo obtener o decodificar la imagen de la camara. Revisa Home Assistant Host y el acceso a la camara desde el servidor ML."
},
"abort": {
"already_configured": "Esta camara ya esta configurada."
}
},
"options": {
"step": {
"init": {
"title": "Ajustes del detector",
"description": "Actualiza la camara, servidor ML, estado de impresion y ajustes de deteccion para este detector.",
"data": {
"home_assistant_host": "Host de Home Assistant",
"obico_host": "Host de la API ML de Obico",
"obico_auth_token": "Token de API ML de Obico",
"camera": "Camara",
"snapshot_url": "URL directa de captura",
"print_status_sensor": "Sensor de estado de impresion",
"active_print_states": "Estados activos de impresion",
"chamber_light": "Luz de camara",
"light_control_mode": "Control de luz",
"light_settle_seconds": "Espera de luz",
"run_without_printing": "Ejecutar deteccion programada sin estado de impresion",
"detection_interval": "Intervalo de deteccion",
"sensitivity": "Sensibilidad",
"warning_threshold": "Umbral de advertencia",
"failure_threshold": "Umbral de fallo",
"cooldown_seconds": "Enfriamiento del evento detectado"
},
"data_description": {
"home_assistant_host": "URL accesible por el servidor ML. Para Docker en otro host LAN, usa la URL LAN de HA, por ejemplo http://192.168.1.90:8123.",
"obico_host": "URL base del servidor ML de este proyecto, por ejemplo http://192.168.1.100:3333. La configuracion comprueba /hc/ y /debug/image.",
"obico_auth_token": "Debe coincidir con ML_API_TOKEN / obico_api_secret configurado en el servidor ML.",
"camera": "Cualquier entidad de camara de HA. Ejemplo de elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. El nombre de tu dispositivo puede variar.",
"snapshot_url": "Opcional. Dejalo vacio para usar la imagen proxy de la camara seleccionada en Home Assistant.",
"print_status_sensor": "Opcional pero recomendado. Ejemplo: sensor.elegoo_centauri_carbon2_print_status. Para nombres estilo Elegoo, el sensor current_status coincidente se usa automaticamente como proteccion extra.",
"active_print_states": "Estados separados por comas que significan impresion, por ejemplo printing,printing_recovery.",
"chamber_light": "Opcional. Ejemplo: light.elegoo_centauri_carbon2_chamber_light. Solo lo usa el ajuste de control de luz.",
"light_control_mode": "Elige si la deteccion no controla la luz, la enciende y la deja encendida, o restaura el estado anterior despues de cada captura.",
"light_settle_seconds": "Segundos que se esperan despues de encender una luz apagada antes de tomar la captura. El valor predeterminado es 3 segundos para exposicion/enfoque.",
"run_without_printing": "Si no se selecciona sensor de estado, la deteccion programada solo se ejecuta cuando esto esta activado. El boton Test siempre ejecuta una comprobacion.",
"detection_interval": "Segundos entre comprobaciones programadas mientras el estado de impresion esta activo. Ejemplos: 600 para 10 minutos, 900 para 15 minutos.",
"warning_threshold": "Se usa cuando Sensibilidad esta en Custom thresholds.",
"failure_threshold": "Se usa cuando Sensibilidad esta en Custom thresholds.",
"cooldown_seconds": "El valor predeterminado es 900 segundos. Con sensor de estado, las comprobaciones programadas emiten un evento detected por ventana activa de impresion; los eventos result siguen emitiendose en cada comprobacion."
}
}
},
"error": {
"warning_above_failure": "El umbral de advertencia debe ser menor o igual que el umbral de fallo.",
"camera_image_unavailable": "La camara seleccionada no expone una URL entity_picture. Prueba otra camara o define una URL directa de captura.",
"ml_health_failed": "La comprobacion de salud del servidor ML fallo. Confirma que el host sea accesible y apunte a la URL base, por ejemplo http://192.168.1.100:3333.",
"ml_auth_failed": "El servidor ML rechazo el token.",
"ml_image_fetch_failed": "El servidor ML no pudo obtener o decodificar la imagen de la camara. Revisa Home Assistant Host y el acceso a la camara desde el servidor ML."
}
},
"services": {
"predict": {
"name": "Predecir spaghetti desde URL",
"description": "Ejecuta el modelo ML de Obico sobre una URL de imagen sin procesar",
"fields": {
"obico_host": {
"name": "Host de la API ML de Obico",
"description": "Host de la API ML de Obico"
},
"obico_auth_token": {
"name": "Token de API ML de Obico",
"description": "Token de autenticacion de la API ML de Obico"
},
"image_url": {
"name": "URL de imagen",
"description": "URL de captura"
}
}
},
"run_detection": {
"name": "Ejecutar deteccion",
"description": "Ejecuta una comprobacion de deteccion para un detector configurado.",
"fields": {
"detector": {
"name": "Detector"
},
"config_entry": {
"name": "Entrada de configuracion"
},
"force": {
"name": "Forzar"
}
}
},
"reset_state": {
"name": "Restablecer estado de deteccion",
"description": "Limpia la confianza, el resultado y el estado de error del detector."
}
}
}
@@ -0,0 +1,150 @@
{
"config": {
"step": {
"user": {
"title": "Elegoo Spaghetti Detection",
"description": "Elegoo प्रिंटर कैमरा के लिए एक detector बनाता है। यह integration केवल failures detect करता है और entities/events बनाता है; pause, stop और notify actions आपकी अपनी automations में रहते हैं।",
"data": {
"name": "Detector name",
"instance_id": "Entity prefix",
"home_assistant_host": "Home Assistant Host",
"obico_host": "Obico ML API Host",
"obico_auth_token": "Obico ML API Auth Token",
"camera": "Camera",
"snapshot_url": "Direct snapshot URL",
"print_status_sensor": "Print status sensor",
"active_print_states": "Active print states",
"chamber_light": "Chamber light",
"light_control_mode": "Light control",
"light_settle_seconds": "Light settle delay",
"run_without_printing": "Print status के बिना scheduled detection चलाएं",
"detection_interval": "Detection interval",
"sensitivity": "Sensitivity",
"warning_threshold": "Warning threshold",
"failure_threshold": "Failure threshold",
"cooldown_seconds": "Detected event cooldown"
},
"data_description": {
"home_assistant_host": "ML server द्वारा reachable URL। किसी दूसरे LAN host पर Docker के लिए HA LAN URL इस्तेमाल करें, जैसे http://192.168.1.90:8123.",
"obico_host": "इस project के ML server का base URL, जैसे http://192.168.1.100:3333। Setup /hc/ और /debug/image check करता है।",
"obico_auth_token": "ML server पर configured ML_API_TOKEN / obico_api_secret से match करना चाहिए।",
"instance_id": "Entity IDs में इस्तेमाल होने वाला stable slug। हर detector के लिए अलग prefix इस्तेमाल करें, जैसे elegoo_cc2_left.",
"camera": "कोई भी HA camera entity। elegoo-homeassistant example: camera.elegoo_centauri_carbon2_chamber_camera. आपका device name अलग हो सकता है।",
"snapshot_url": "Optional। Selected camera entity की Home Assistant camera proxy image इस्तेमाल करने के लिए खाली छोड़ें। इसे केवल unusual cameras के लिए इस्तेमाल करें।",
"print_status_sensor": "Optional लेकिन recommended। Example: sensor.elegoo_centauri_carbon2_print_status. Elegoo-style names में matching current_status sensor अपने आप extra guard के रूप में इस्तेमाल होता है।",
"active_print_states": "Printing बताने वाले comma-separated states, जैसे printing,printing_recovery. Elegoo CC2 आम तौर पर printing इस्तेमाल करता है।",
"chamber_light": "Optional। Example: light.elegoo_centauri_carbon2_chamber_light. केवल light-control setting द्वारा इस्तेमाल होता है।",
"light_control_mode": "चुनें कि detection light को न छुए, उसे on करके on रखे, या हर snapshot के बाद पिछली state restore करे।",
"light_settle_seconds": "Integration द्वारा off light को on करने के बाद snapshot से पहले wait करने के seconds। Camera exposure/focus के लिए default 3 seconds है।",
"run_without_printing": "अगर print status sensor selected नहीं है, scheduled detection केवल यह enabled होने पर चलता है। Test button हमेशा एक check चलाता है।",
"detection_interval": "Print status active होने पर scheduled checks के बीच seconds। Examples: 10 minutes के लिए 600, 15 minutes के लिए 900.",
"warning_threshold": "Sensitivity Custom thresholds होने पर इस्तेमाल होता है।",
"failure_threshold": "Sensitivity Custom thresholds होने पर इस्तेमाल होता है।",
"cooldown_seconds": "Default 900 seconds है। Print status sensor के साथ scheduled checks हर active print window में एक detected event emit करते हैं; result events हर check पर आते रहते हैं।"
}
}
},
"error": {
"invalid_instance_id": "Entity prefix में कम से कम एक valid slug character होना चाहिए।",
"instance_id_exists": "यह entity prefix पहले से किसी दूसरे detector द्वारा इस्तेमाल हो रहा है।",
"warning_above_failure": "Warning threshold failure threshold से कम या उसके बराबर होना चाहिए।",
"already_configured": "यह camera पहले से configured है।",
"camera_image_unavailable": "Selected camera entity_picture URL expose नहीं करता। दूसरा camera try करें या direct snapshot URL set करें।",
"ml_health_failed": "ML server health check failed। Confirm करें कि host reachable है और base URL पर point करता है, जैसे http://192.168.1.100:3333.",
"ml_auth_failed": "ML server ने token reject किया।",
"ml_image_fetch_failed": "ML server camera image fetch या decode नहीं कर सका। Home Assistant Host और ML server से camera access check करें।"
},
"abort": {
"already_configured": "यह camera पहले से configured है।"
}
},
"options": {
"step": {
"init": {
"title": "Detector settings",
"description": "इस detector के लिए camera, ML server, print-state और detection settings update करें।",
"data": {
"home_assistant_host": "Home Assistant Host",
"obico_host": "Obico ML API Host",
"obico_auth_token": "Obico ML API Auth Token",
"camera": "Camera",
"snapshot_url": "Direct snapshot URL",
"print_status_sensor": "Print status sensor",
"active_print_states": "Active print states",
"chamber_light": "Chamber light",
"light_control_mode": "Light control",
"light_settle_seconds": "Light settle delay",
"run_without_printing": "Print status के बिना scheduled detection चलाएं",
"detection_interval": "Detection interval",
"sensitivity": "Sensitivity",
"warning_threshold": "Warning threshold",
"failure_threshold": "Failure threshold",
"cooldown_seconds": "Detected event cooldown"
},
"data_description": {
"home_assistant_host": "ML server द्वारा reachable URL। किसी दूसरे LAN host पर Docker के लिए HA LAN URL इस्तेमाल करें, जैसे http://192.168.1.90:8123.",
"obico_host": "इस project के ML server का base URL, जैसे http://192.168.1.100:3333। Setup /hc/ और /debug/image check करता है।",
"obico_auth_token": "ML server पर configured ML_API_TOKEN / obico_api_secret से match करना चाहिए।",
"camera": "कोई भी HA camera entity। elegoo-homeassistant example: camera.elegoo_centauri_carbon2_chamber_camera. आपका device name अलग हो सकता है।",
"snapshot_url": "Optional। Selected camera entity की Home Assistant camera proxy image इस्तेमाल करने के लिए खाली छोड़ें।",
"print_status_sensor": "Optional लेकिन recommended। Example: sensor.elegoo_centauri_carbon2_print_status. Elegoo-style names में matching current_status sensor अपने आप extra guard के रूप में इस्तेमाल होता है।",
"active_print_states": "Printing बताने वाले comma-separated states, जैसे printing,printing_recovery.",
"chamber_light": "Optional। Example: light.elegoo_centauri_carbon2_chamber_light. केवल light-control setting द्वारा इस्तेमाल होता है।",
"light_control_mode": "चुनें कि detection light को न छुए, उसे on करके on रखे, या हर snapshot के बाद पिछली state restore करे।",
"light_settle_seconds": "Integration द्वारा off light को on करने के बाद snapshot से पहले wait करने के seconds। Camera exposure/focus के लिए default 3 seconds है।",
"run_without_printing": "अगर print status sensor selected नहीं है, scheduled detection केवल यह enabled होने पर चलता है। Test button हमेशा एक check चलाता है।",
"detection_interval": "Print status active होने पर scheduled checks के बीच seconds। Examples: 10 minutes के लिए 600, 15 minutes के लिए 900.",
"warning_threshold": "Sensitivity Custom thresholds होने पर इस्तेमाल होता है।",
"failure_threshold": "Sensitivity Custom thresholds होने पर इस्तेमाल होता है।",
"cooldown_seconds": "Default 900 seconds है। Print status sensor के साथ scheduled checks हर active print window में एक detected event emit करते हैं; result events हर check पर आते रहते हैं।"
}
}
},
"error": {
"warning_above_failure": "Warning threshold failure threshold से कम या उसके बराबर होना चाहिए।",
"camera_image_unavailable": "Selected camera entity_picture URL expose नहीं करता। दूसरा camera try करें या direct snapshot URL set करें।",
"ml_health_failed": "ML server health check failed। Confirm करें कि host reachable है और base URL पर point करता है, जैसे http://192.168.1.100:3333.",
"ml_auth_failed": "ML server ने token reject किया।",
"ml_image_fetch_failed": "ML server camera image fetch या decode नहीं कर सका। Home Assistant Host और ML server से camera access check करें।"
}
},
"services": {
"predict": {
"name": "URL से spaghetti predict करें",
"description": "Raw image URL पर Obico ML model चलाता है",
"fields": {
"obico_host": {
"name": "Obico ML API Host",
"description": "Obico ML API host"
},
"obico_auth_token": {
"name": "Obico ML API Auth Token",
"description": "Obico ML API authentication token"
},
"image_url": {
"name": "Image URL",
"description": "Snapshot URL"
}
}
},
"run_detection": {
"name": "Detection चलाएं",
"description": "Configured detector के लिए एक detection check चलाता है।",
"fields": {
"detector": {
"name": "Detector"
},
"config_entry": {
"name": "Config entry"
},
"force": {
"name": "Force"
}
}
},
"reset_state": {
"name": "Detection state reset करें",
"description": "Detector confidence, result और error state साफ करता है।"
}
}
}
@@ -0,0 +1,150 @@
{
"config": {
"step": {
"user": {
"title": "Elegoo Spaghetti Detection",
"description": "Elegoo yazici kamerasi icin bir algilayici olusturur. Entegrasyon yalnizca hatalari algilar ve entity/event uretir; pause, stop ve bildirim aksiyonlari kendi otomasyonlarinizda kalir.",
"data": {
"name": "Algilayici adi",
"instance_id": "Entity on eki",
"home_assistant_host": "Home Assistant Host",
"obico_host": "Obico ML API Host",
"obico_auth_token": "Obico ML API Auth Token",
"camera": "Kamera",
"snapshot_url": "Dogrudan snapshot URL",
"print_status_sensor": "Baski durum sensoru",
"active_print_states": "Aktif baski durumlari",
"chamber_light": "Kabin isigi",
"light_control_mode": "Isik kontrolu",
"light_settle_seconds": "Isik bekleme suresi",
"run_without_printing": "Baski durumu olmadan zamanlanmis algilama calistir",
"detection_interval": "Algilama araligi",
"sensitivity": "Hassasiyet",
"warning_threshold": "Uyari esigi",
"failure_threshold": "Hata esigi",
"cooldown_seconds": "Algilandi eventi bekleme suresi"
},
"data_description": {
"home_assistant_host": "ML sunucusunun erisebildigi URL. Baska bir LAN makinesindeki Docker icin HA LAN URL kullanin; ornegin http://192.168.1.90:8123.",
"obico_host": "Bu projenin ML sunucusu base URL adresi; ornegin http://192.168.1.100:3333. Kurulum /hc/ ve /debug/image kontrollerini yapar.",
"obico_auth_token": "ML sunucusunda ayarlanan ML_API_TOKEN / obico_api_secret ile ayni olmalidir.",
"instance_id": "Entity ID'lerinde kullanilan kalici slug. Her algilayici icin farkli bir on ek kullanin; ornegin elegoo_cc2_left.",
"camera": "Herhangi bir HA kamera entity'si. elegoo-homeassistant ornegi: camera.elegoo_centauri_carbon2_chamber_camera. Cihaz adiniz farkli olabilir.",
"snapshot_url": "Istege bagli. Secilen kamera entity'sinin Home Assistant camera proxy gorselini kullanmak icin bos birakin. Bunu yalnizca ozel kamera durumlarinda kullanin.",
"print_status_sensor": "Istege bagli ama onerilir. Ornek: sensor.elegoo_centauri_carbon2_print_status. Elegoo tarzi adlarda eslesen current_status sensoru otomatik ek koruma olarak kullanilir.",
"active_print_states": "Baski anlamina gelen virgulle ayrilmis durumlar; ornegin printing,printing_recovery. Elegoo CC2 genelde printing kullanir.",
"chamber_light": "Istege bagli. Ornek: light.elegoo_centauri_carbon2_chamber_light. Yalnizca isik kontrol ayari tarafindan kullanilir.",
"light_control_mode": "Algilama isigi hic kontrol etmesin mi, acip acik mi biraksin, yoksa her snapshot sonrasinda onceki duruma mi dondursun secin.",
"light_settle_seconds": "Bu entegrasyon kapali isigi actiktan sonra snapshot almadan once bekleyecegi saniye. Kamera pozlama/netleme icin varsayilan 3 saniyedir.",
"run_without_printing": "Baski durum sensoru secilmediyse zamanlanmis algilama yalnizca bu ayar acikken calisir. Test butonu her zaman tek kontrol calistirir.",
"detection_interval": "Baski durumu aktifken zamanlanmis kontroller arasindaki saniye. Ornek: 10 dakika icin 600, 15 dakika icin 900.",
"warning_threshold": "Hassasiyet Custom thresholds oldugunda kullanilir.",
"failure_threshold": "Hassasiyet Custom thresholds oldugunda kullanilir.",
"cooldown_seconds": "Varsayilan 900 saniyedir. Baski durum sensoru olan zamanlanmis kontroller aktif baski penceresi basina bir detected event uretir; result event'leri her kontrolde gelmeye devam eder."
}
}
},
"error": {
"invalid_instance_id": "Entity on eki en az bir gecerli slug karakteri icermelidir.",
"instance_id_exists": "Bu entity on eki baska bir algilayici tarafindan kullaniliyor.",
"warning_above_failure": "Uyari esigi hata esiginden kucuk veya ona esit olmalidir.",
"already_configured": "Bu kamera zaten yapilandirilmis.",
"camera_image_unavailable": "Secilen kamera entity_picture URL sunmuyor. Baska kamera deneyin veya dogrudan snapshot URL ayarlayin.",
"ml_health_failed": "ML sunucusu saglik kontrolu basarisiz oldu. Host erisilebilir olmali ve base URL'ye isaret etmelidir; ornegin http://192.168.1.100:3333.",
"ml_auth_failed": "ML sunucusu token'i reddetti.",
"ml_image_fetch_failed": "ML sunucusu kamera gorselini alamadi veya decode edemedi. Home Assistant Host ve kamera erisimini ML sunucusundan kontrol edin."
},
"abort": {
"already_configured": "Bu kamera zaten yapilandirilmis."
}
},
"options": {
"step": {
"init": {
"title": "Algilayici ayarlari",
"description": "Bu algilayici icin kamera, ML sunucusu, baski durumu ve algilama ayarlarini guncelleyin.",
"data": {
"home_assistant_host": "Home Assistant Host",
"obico_host": "Obico ML API Host",
"obico_auth_token": "Obico ML API Auth Token",
"camera": "Kamera",
"snapshot_url": "Dogrudan snapshot URL",
"print_status_sensor": "Baski durum sensoru",
"active_print_states": "Aktif baski durumlari",
"chamber_light": "Kabin isigi",
"light_control_mode": "Isik kontrolu",
"light_settle_seconds": "Isik bekleme suresi",
"run_without_printing": "Baski durumu olmadan zamanlanmis algilama calistir",
"detection_interval": "Algilama araligi",
"sensitivity": "Hassasiyet",
"warning_threshold": "Uyari esigi",
"failure_threshold": "Hata esigi",
"cooldown_seconds": "Algilandi eventi bekleme suresi"
},
"data_description": {
"home_assistant_host": "ML sunucusunun erisebildigi URL. Baska bir LAN makinesindeki Docker icin HA LAN URL kullanin; ornegin http://192.168.1.90:8123.",
"obico_host": "Bu projenin ML sunucusu base URL adresi; ornegin http://192.168.1.100:3333. Kurulum /hc/ ve /debug/image kontrollerini yapar.",
"obico_auth_token": "ML sunucusunda ayarlanan ML_API_TOKEN / obico_api_secret ile ayni olmalidir.",
"camera": "Herhangi bir HA kamera entity'si. elegoo-homeassistant ornegi: camera.elegoo_centauri_carbon2_chamber_camera. Cihaz adiniz farkli olabilir.",
"snapshot_url": "Istege bagli. Secilen kamera entity'sinin Home Assistant camera proxy gorselini kullanmak icin bos birakin.",
"print_status_sensor": "Istege bagli ama onerilir. Ornek: sensor.elegoo_centauri_carbon2_print_status. Elegoo tarzi adlarda eslesen current_status sensoru otomatik ek koruma olarak kullanilir.",
"active_print_states": "Baski anlamina gelen virgulle ayrilmis durumlar; ornegin printing,printing_recovery.",
"chamber_light": "Istege bagli. Ornek: light.elegoo_centauri_carbon2_chamber_light. Yalnizca isik kontrol ayari tarafindan kullanilir.",
"light_control_mode": "Algilama isigi hic kontrol etmesin mi, acip acik mi biraksin, yoksa her snapshot sonrasinda onceki duruma mi dondursun secin.",
"light_settle_seconds": "Bu entegrasyon kapali isigi actiktan sonra snapshot almadan once bekleyecegi saniye. Kamera pozlama/netleme icin varsayilan 3 saniyedir.",
"run_without_printing": "Baski durum sensoru secilmediyse zamanlanmis algilama yalnizca bu ayar acikken calisir. Test butonu her zaman tek kontrol calistirir.",
"detection_interval": "Baski durumu aktifken zamanlanmis kontroller arasindaki saniye. Ornek: 10 dakika icin 600, 15 dakika icin 900.",
"warning_threshold": "Hassasiyet Custom thresholds oldugunda kullanilir.",
"failure_threshold": "Hassasiyet Custom thresholds oldugunda kullanilir.",
"cooldown_seconds": "Varsayilan 900 saniyedir. Baski durum sensoru olan zamanlanmis kontroller aktif baski penceresi basina bir detected event uretir; result event'leri her kontrolde gelmeye devam eder."
}
}
},
"error": {
"warning_above_failure": "Uyari esigi hata esiginden kucuk veya ona esit olmalidir.",
"camera_image_unavailable": "Secilen kamera entity_picture URL sunmuyor. Baska kamera deneyin veya dogrudan snapshot URL ayarlayin.",
"ml_health_failed": "ML sunucusu saglik kontrolu basarisiz oldu. Host erisilebilir olmali ve base URL'ye isaret etmelidir; ornegin http://192.168.1.100:3333.",
"ml_auth_failed": "ML sunucusu token'i reddetti.",
"ml_image_fetch_failed": "ML sunucusu kamera gorselini alamadi veya decode edemedi. Home Assistant Host ve kamera erisimini ML sunucusundan kontrol edin."
}
},
"services": {
"predict": {
"name": "URL'den spaghetti tahmini",
"description": "Obico ML modelini ham bir gorsel URL'si uzerinde calistirir",
"fields": {
"obico_host": {
"name": "Obico ML API Host",
"description": "Obico ML API host"
},
"obico_auth_token": {
"name": "Obico ML API Auth Token",
"description": "Obico ML API kimlik dogrulama token'i"
},
"image_url": {
"name": "Gorsel URL",
"description": "Snapshot URL"
}
}
},
"run_detection": {
"name": "Algilama calistir",
"description": "Yapilandirilmis bir algilayici icin tek algilama kontrolu calistirir.",
"fields": {
"detector": {
"name": "Algilayici"
},
"config_entry": {
"name": "Config entry"
},
"force": {
"name": "Zorla"
}
}
},
"reset_state": {
"name": "Algilama durumunu sifirla",
"description": "Algilayici confidence, sonuc ve hata durumunu temizler."
}
}
}
@@ -0,0 +1,150 @@
{
"config": {
"step": {
"user": {
"title": "Elegoo Spaghetti Detection",
"description": "为 Elegoo 打印机摄像头创建一个检测器。该集成只检测失败并产生实体/事件;暂停、停止和通知动作仍由你的自动化处理。",
"data": {
"name": "检测器名称",
"instance_id": "实体前缀",
"home_assistant_host": "Home Assistant 主机",
"obico_host": "Obico ML API 主机",
"obico_auth_token": "Obico ML API 令牌",
"camera": "摄像头",
"snapshot_url": "直接快照 URL",
"print_status_sensor": "打印状态传感器",
"active_print_states": "活动打印状态",
"chamber_light": "腔体灯",
"light_control_mode": "灯光控制",
"light_settle_seconds": "灯光稳定延迟",
"run_without_printing": "无打印状态时运行计划检测",
"detection_interval": "检测间隔",
"sensitivity": "灵敏度",
"warning_threshold": "警告阈值",
"failure_threshold": "失败阈值",
"cooldown_seconds": "检测事件冷却时间"
},
"data_description": {
"home_assistant_host": "ML 服务器可访问的 URL。Docker 在另一台局域网主机上运行时,请使用 HA 的局域网 URL,例如 http://192.168.1.90:8123。",
"obico_host": "本项目 ML 服务器的基础 URL,例如 http://192.168.1.100:3333。配置会检查 /hc/ 和 /debug/image。",
"obico_auth_token": "必须与 ML 服务器上配置的 ML_API_TOKEN / obico_api_secret 匹配。",
"instance_id": "用于实体 ID 的稳定 slug。每个检测器使用不同前缀,例如 elegoo_cc2_left。",
"camera": "任意 HA 摄像头实体。elegoo-homeassistant 示例:camera.elegoo_centauri_carbon2_chamber_camera。你的设备名称可能不同。",
"snapshot_url": "可选。留空则使用所选摄像头实体的 Home Assistant camera proxy 图像。仅在特殊摄像头场景中使用。",
"print_status_sensor": "可选但推荐。示例:sensor.elegoo_centauri_carbon2_print_status。对于 Elegoo 风格命名,匹配的 current_status 传感器会自动作为额外保护。",
"active_print_states": "表示正在打印的逗号分隔状态,例如 printing,printing_recovery。Elegoo CC2 通常使用 printing。",
"chamber_light": "可选。示例:light.elegoo_centauri_carbon2_chamber_light。仅由灯光控制设置使用。",
"light_control_mode": "选择检测时不控制灯光、打开并保持开启,或每次快照后恢复之前的灯光状态。",
"light_settle_seconds": "集成打开原本关闭的灯光后,拍摄快照前等待的秒数。默认 3 秒,用于曝光/对焦。",
"run_without_printing": "未选择打印状态传感器时,计划检测只会在启用此项后运行。测试按钮始终运行一次检查。",
"detection_interval": "打印状态活动时,两次计划检查之间的秒数。示例:600 表示 10 分钟,900 表示 15 分钟。",
"warning_threshold": "当灵敏度设置为 Custom thresholds 时使用。",
"failure_threshold": "当灵敏度设置为 Custom thresholds 时使用。",
"cooldown_seconds": "默认 900 秒。带打印状态传感器的计划检查在每个活动打印窗口只发出一个 detected 事件;result 事件仍会在每次检查时发出。"
}
}
},
"error": {
"invalid_instance_id": "实体前缀必须至少包含一个有效的 slug 字符。",
"instance_id_exists": "此实体前缀已被另一个检测器使用。",
"warning_above_failure": "警告阈值必须小于或等于失败阈值。",
"already_configured": "此摄像头已配置。",
"camera_image_unavailable": "所选摄像头没有提供 entity_picture URL。请尝试其他摄像头或设置直接快照 URL。",
"ml_health_failed": "ML 服务器健康检查失败。请确认主机可访问并指向基础 URL,例如 http://192.168.1.100:3333。",
"ml_auth_failed": "ML 服务器拒绝了令牌。",
"ml_image_fetch_failed": "ML 服务器无法获取或解码摄像头图像。请检查 Home Assistant Host 以及 ML 服务器对摄像头的访问。"
},
"abort": {
"already_configured": "此摄像头已配置。"
}
},
"options": {
"step": {
"init": {
"title": "检测器设置",
"description": "更新此检测器的摄像头、ML 服务器、打印状态和检测设置。",
"data": {
"home_assistant_host": "Home Assistant 主机",
"obico_host": "Obico ML API 主机",
"obico_auth_token": "Obico ML API 令牌",
"camera": "摄像头",
"snapshot_url": "直接快照 URL",
"print_status_sensor": "打印状态传感器",
"active_print_states": "活动打印状态",
"chamber_light": "腔体灯",
"light_control_mode": "灯光控制",
"light_settle_seconds": "灯光稳定延迟",
"run_without_printing": "无打印状态时运行计划检测",
"detection_interval": "检测间隔",
"sensitivity": "灵敏度",
"warning_threshold": "警告阈值",
"failure_threshold": "失败阈值",
"cooldown_seconds": "检测事件冷却时间"
},
"data_description": {
"home_assistant_host": "ML 服务器可访问的 URL。Docker 在另一台局域网主机上运行时,请使用 HA 的局域网 URL,例如 http://192.168.1.90:8123。",
"obico_host": "本项目 ML 服务器的基础 URL,例如 http://192.168.1.100:3333。配置会检查 /hc/ 和 /debug/image。",
"obico_auth_token": "必须与 ML 服务器上配置的 ML_API_TOKEN / obico_api_secret 匹配。",
"camera": "任意 HA 摄像头实体。elegoo-homeassistant 示例:camera.elegoo_centauri_carbon2_chamber_camera。你的设备名称可能不同。",
"snapshot_url": "可选。留空则使用所选摄像头实体的 Home Assistant camera proxy 图像。",
"print_status_sensor": "可选但推荐。示例:sensor.elegoo_centauri_carbon2_print_status。对于 Elegoo 风格命名,匹配的 current_status 传感器会自动作为额外保护。",
"active_print_states": "表示正在打印的逗号分隔状态,例如 printing,printing_recovery。",
"chamber_light": "可选。示例:light.elegoo_centauri_carbon2_chamber_light。仅由灯光控制设置使用。",
"light_control_mode": "选择检测时不控制灯光、打开并保持开启,或每次快照后恢复之前的灯光状态。",
"light_settle_seconds": "集成打开原本关闭的灯光后,拍摄快照前等待的秒数。默认 3 秒,用于曝光/对焦。",
"run_without_printing": "未选择打印状态传感器时,计划检测只会在启用此项后运行。测试按钮始终运行一次检查。",
"detection_interval": "打印状态活动时,两次计划检查之间的秒数。示例:600 表示 10 分钟,900 表示 15 分钟。",
"warning_threshold": "当灵敏度设置为 Custom thresholds 时使用。",
"failure_threshold": "当灵敏度设置为 Custom thresholds 时使用。",
"cooldown_seconds": "默认 900 秒。带打印状态传感器的计划检查在每个活动打印窗口只发出一个 detected 事件;result 事件仍会在每次检查时发出。"
}
}
},
"error": {
"warning_above_failure": "警告阈值必须小于或等于失败阈值。",
"camera_image_unavailable": "所选摄像头没有提供 entity_picture URL。请尝试其他摄像头或设置直接快照 URL。",
"ml_health_failed": "ML 服务器健康检查失败。请确认主机可访问并指向基础 URL,例如 http://192.168.1.100:3333。",
"ml_auth_failed": "ML 服务器拒绝了令牌。",
"ml_image_fetch_failed": "ML 服务器无法获取或解码摄像头图像。请检查 Home Assistant Host 以及 ML 服务器对摄像头的访问。"
}
},
"services": {
"predict": {
"name": "从 URL 预测 spaghetti",
"description": "针对原始图像 URL 运行 Obico ML 模型",
"fields": {
"obico_host": {
"name": "Obico ML API 主机",
"description": "Obico ML API 主机"
},
"obico_auth_token": {
"name": "Obico ML API 令牌",
"description": "Obico ML API 认证令牌"
},
"image_url": {
"name": "图像 URL",
"description": "快照 URL"
}
}
},
"run_detection": {
"name": "运行检测",
"description": "为已配置的检测器运行一次检测检查。",
"fields": {
"detector": {
"name": "检测器"
},
"config_entry": {
"name": "配置项"
},
"force": {
"name": "强制"
}
}
},
"reset_state": {
"name": "重置检测状态",
"description": "清除检测器的置信度、结果和错误状态。"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
---
services:
ha_elegoo_spaghetti_detection:
build:
context: ./addon
dockerfile: Dockerfile.standalone.base
image: hepter/ha_elegoo_spaghetti_detection_standalone:latest
container_name: ha_elegoo_spaghetti_detection
restart: unless-stopped
ports:
- 3333:3333/tcp
environment:
- ML_API_TOKEN=obico_api_secret
- ML_USE_GPU=false
- ML_MODEL_BACKEND=onnx
- GUNICORN_TIMEOUT=120
- GUNICORN_WORKERS=1
- TZ=Europe/Istanbul
+107
View File
@@ -0,0 +1,107 @@
# HACS Publishing Checklist
This repository is a HACS `integration` because it installs a Home Assistant
custom integration under `custom_components/elegoo_spaghetti_detection`.
It is not a HACS `plugin`/Dashboard item. Dashboard plugins are JavaScript
frontend assets, usually installed from `dist/`.
## Current HACS Requirements
For a custom integration repository:
- The repository must be public and hosted on GitHub.
- The repository must have a clear GitHub description.
- The repository must have GitHub topics.
- GitHub issues must be enabled.
- The repository must have a README that explains how to use the integration.
- `hacs.json` must exist in the repository root and contain at least `name`.
- There must be only one integration directory under `custom_components/`.
- All files required for the integration itself must be inside
`custom_components/elegoo_spaghetti_detection/`.
- The integration `manifest.json` must define at least:
- `domain`
- `documentation`
- `issue_tracker`
- `codeowners`
- `name`
- `version`
- The integration must provide brand assets. This repo includes:
- `custom_components/elegoo_spaghetti_detection/brand/icon.png`
- `custom_components/elegoo_spaghetti_detection/brand/logo.png`
- If submitted as a default HACS repository, these GitHub Actions must pass:
- HACS Action with `category: integration`
- Hassfest
- A full GitHub release is required before submitting to `hacs/default`. A tag
alone is not enough.
## Default Store Submission
To request inclusion in the default HACS store:
1. Confirm the repository can be added manually as a HACS custom repository.
2. Confirm HACS Action passes without errors or ignored checks.
3. Confirm Hassfest passes.
4. Create a full GitHub release, for example `v1.0.0`.
5. Fork `hacs/default`.
6. Add `hepter/ha-elegoo-spaghetti-detection` alphabetically to the
`integration` file.
7. Open a PR from a branch in the fork. Do not submit the PR from an
organization account, because the PR must be editable.
HACS default repository reviews can take months. Until it is accepted, users can
install this repo through HACS as a custom repository.
## Repository Metadata To Set On GitHub
These were set on GitHub on 2026-05-01. Verify them before opening a HACS
default PR:
- Description:
- `Elegoo FDM printer spaghetti detection for Home Assistant with a local Obico ML server`
- Topics:
- `home-assistant`
- `hacs`
- `hacs-integration`
- `custom-integration`
- `elegoo`
- `fdm`
- `3d-printer`
- `spaghetti-detection`
- `obico`
- Issues:
- Enabled
## Workflows In This Repo
- `.github/workflows/validate.yaml`
- Runs `hacs/action@main` with `category: integration`.
- `.github/workflows/hassfest.yaml`
- Runs `home-assistant/actions/hassfest@master`.
- `.github/workflows/ci.yaml`
- Runs basic JSON, Python syntax, and YAML validation.
- `.github/dependabot.yml`
- Keeps GitHub Actions versions current.
## Release Notes
For the first HACS-ready release:
- Use a SemVer tag such as `v1.0.0`.
- Ensure `custom_components/elegoo_spaghetti_detection/manifest.json`
contains the matching version without the leading `v`, for example `1.0.0`.
- Publish a full GitHub release after workflows pass.
## References
- HACS publish general requirements:
- https://hacs.xyz/docs/publish/start/
- HACS integration requirements:
- https://hacs.xyz/docs/publish/integration/
- HACS default repository inclusion:
- https://hacs.xyz/docs/publish/include/
- HACS validation action:
- https://hacs.xyz/docs/publish/action/
- Home Assistant integration manifest:
- https://developers.home-assistant.io/docs/creating_integration_manifest/
- Local custom integration brand assets:
- https://developers.home-assistant.io/blog/2026/02/24/brands-proxy-api
+67
View File
@@ -0,0 +1,67 @@
# Automation Examples
The integration only detects failures and emits entities/events. Printer actions
use your own Home Assistant entities directly.
Example Elegoo CC2 entities used by the templates:
```text
button.elegoo_centauri_carbon2_pause_print
button.elegoo_centauri_carbon2_resume_print
button.elegoo_centauri_carbon2_stop_print
camera.elegoo_centauri_carbon2_chamber_camera
sensor.elegoo_centauri_carbon2_print_status
```
Your entity IDs may differ if your printer/device name differs.
## Included Examples
- [Notify only](../examples/notify_only.yaml)
- [Actionable mobile notification with pause/stop/resume](../examples/actionable_notification.yaml)
- [Confidence-based pause/stop](../examples/smart_pause_stop_by_confidence.yaml)
- [Manual test notification](../examples/manual_test_notification.yaml)
## Event Data
Use `elegoo_spaghetti_detection_detected` for notifications and printer
actions. When a print status sensor is configured, scheduled detected events are
sent once per active print window to avoid repeated pause/notify loops. Use
`elegoo_spaghetti_detection_result` only when you intentionally want every
detection result, including clear and warning checks.
These two events are intentionally different:
| Event | When it fires | Use for notifications/actions? |
| --- | --- | --- |
| `elegoo_spaghetti_detection_detected` | Only when a failure is detected and the active print window has not already emitted one detected event. | Yes. Use this for Pushbullet, mobile notifications, pause, and stop automations. |
| `elegoo_spaghetti_detection_result` | Every completed detection check, including clear, warning, and repeated detected checks. | Usually no. Use it only for logging, dashboards, or advanced automations that implement their own throttling. |
If an automation sends notifications from
`elegoo_spaghetti_detection_result`, it can still notify repeatedly every
detection interval. The included notification and pause/stop examples use
`elegoo_spaghetti_detection_detected` to avoid that.
Use these fields in templates:
```text
trigger.event.data.confidence
trigger.event.data.raw_score
trigger.event.data.detected
trigger.event.data.detections
trigger.event.data.image_url
trigger.event.data.printer_state
trigger.event.data.status
trigger.event.data.last_error
```
Confidence is a number between `0` and `1`. For notification text:
```jinja
{{ (trigger.event.data.confidence | float(0) * 100) | round(1) }}%
```
The `image_url` field is the exact camera snapshot URL the ML server checked.
Mobile notifications can use it as an image attachment. If your phone is away
from the LAN, make sure the `Home Assistant Host` you configured is reachable
from that phone, or use a notification-only message without the image.
+124
View File
@@ -0,0 +1,124 @@
# Configuration
Open:
```text
Settings -> Devices & services -> Add integration -> Elegoo Spaghetti Detection
```
![Elegoo Spaghetti Detection setup form](images/config-flow-add-hub.png)
The setup form creates one detector. Add another detector for another camera.
Existing detector settings are reused as defaults to reduce repeated server
entry.
## Fields
| Field | Notes |
| --- | --- |
| `Detector name` | Display name for this camera/detector. |
| `Entity prefix` | Stable entity ID prefix, for example `elegoo_spaghetti_detection` or `elegoo_cc2_left`. |
| `Home Assistant Host` | URL reachable by the ML server. For Docker on another LAN host, prefer the HA LAN URL, for example `http://192.168.1.90:8123`. Do not use `homeassistant.local` unless the Docker host can resolve mDNS. |
| `Obico ML API Host` | Base URL of this project's ML server, for example `http://192.168.1.100:3333`. Do not enter `/hc/` or `/p/`. |
| `Obico ML API Auth Token` | Must match `ML_API_TOKEN` / `obico_api_secret` configured on the ML server. |
| `Camera` | Any HA camera entity. Example: `camera.elegoo_centauri_carbon2_chamber_camera`. Your device name may differ. |
| `Direct snapshot URL` | Optional. Leave empty to use the selected camera's HA camera proxy image. Use this only for unusual camera integrations. |
| `Print status sensor` | Optional but recommended. Example: `sensor.elegoo_centauri_carbon2_print_status`. Scheduled detection runs only when this entity is in an active print state. For Elegoo-style entity names, a matching `sensor.<printer>_current_status` is used automatically as an extra guard; you do not select it separately. |
| `Active print states` | Comma-separated states that mean printing. For Elegoo CC2, `printing` is usually enough. You can use `printing,printing_recovery`. |
| `Chamber light` | Optional. Example: `light.elegoo_centauri_carbon2_chamber_light`. |
| `Light control` | `Do not control light`, `Turn on before detection and leave on`, or `Restore previous state after detection`. Restore mode only turns the light off again when it was off before this detection cycle. |
| `Light settle delay` | Seconds to wait after the integration turns on an off light before taking the snapshot. Default is `3`; useful for camera exposure/focus. |
| `Run scheduled detection without print status` | Keep off unless this detector is camera-only and has no print status entity. |
| `Detection interval` | Seconds between scheduled checks while the print status is active. Examples: `600` for 10 minutes, `900` for 15 minutes. |
| `Sensitivity` | High, normal, low, or custom thresholds. |
| `Warning/Failure threshold` | Used when sensitivity is `Custom thresholds`. |
| `Detected event cooldown` | Minimum seconds between detected events. Default is `900` seconds. Scheduled checks with a print status sensor use the active print window limit instead, so this is mainly a fallback for camera-only setups and manual testing. |
## Print-State Guarding
The setup form asks for one status entity: `Print status sensor`.
For Elegoo printers, select the `print_status` entity:
```text
sensor.elegoo_centauri_carbon2_print_status
```
If the selected entity ends with `_print_status`, the integration automatically
checks for a sibling `_current_status` entity:
```text
sensor.elegoo_centauri_carbon2_current_status
```
There is no separate field for `current_status`. It is an automatic fallback
guard. Scheduled detection runs only when the selected `print_status` is active
and the inferred `current_status`, when present, is also active.
Example:
```text
print_status = printing
current_status = idle
result: scheduled detection does not run
```
This protects against printer/integration states where `print_status` remains
`printing` during homing, idle, or other non-print movement states.
## Repeated Notification Guarding
When a `Print status sensor` is selected, scheduled detection emits at most one
`elegoo_spaghetti_detection_detected` event while the printer remains in one of
the configured `Active print states`. If the selected status sensor or the
automatic `current_status` guard leaves the active states and later returns to
an active state, a new failure can emit one new detected event.
This is separate from the fallback guard above. The fallback guard decides
whether scheduled detection should run. The repeated-notification guard decides
whether a detected result should fire another notification/action event.
## Example Elegoo CC2 Values
These are examples from one `elegoo-homeassistant` install. Your entity IDs may
change if the Home Assistant device name differs.
```text
Entity prefix: elegoo_spaghetti_detection
Home Assistant Host: http://192.168.1.90:8123
Obico ML API Host: http://192.168.1.100:3333
Obico ML API Auth Token: obico_api_secret
Camera: camera.elegoo_centauri_carbon2_chamber_camera
Print status sensor: sensor.elegoo_centauri_carbon2_print_status
Active print states: printing
Chamber light: light.elegoo_centauri_carbon2_chamber_light
Light control: Restore previous state after detection
Light settle delay: 3
```
With restore mode, a scheduled check does this:
```text
light was off -> turn on -> wait -> snapshot/detect -> turn off
light was on -> snapshot/detect -> keep on
```
## Created Entities
With prefix `elegoo_spaghetti_detection`, the integration creates:
```text
binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
sensor.elegoo_spaghetti_detection_confidence
sensor.elegoo_spaghetti_detection_raw_score
sensor.elegoo_spaghetti_detection_detections
sensor.elegoo_spaghetti_detection_status
sensor.elegoo_spaghetti_detection_last_error
sensor.elegoo_spaghetti_detection_last_run
sensor.elegoo_spaghetti_detection_next_run
button.elegoo_spaghetti_detection_test_spaghetti_detection
button.elegoo_spaghetti_detection_reset_detection_state
```
Press `Test Spaghetti Detection` to run one check immediately from the current
camera image, even if the printer is not printing.
+109
View File
@@ -0,0 +1,109 @@
# Dashboard Examples
These examples create a small Home Assistant dashboard section for one
spaghetti detector.
Replace entity IDs if your detector prefix, camera, printer, or automation names
are different. The examples use:
```text
camera.elegoo_centauri_carbon2_chamber_camera
sensor.elegoo_centauri_carbon2_print_status
sensor.elegoo_spaghetti_detection_status
sensor.elegoo_spaghetti_detection_next_run
sensor.elegoo_spaghetti_detection_last_run
sensor.elegoo_spaghetti_detection_last_error
sensor.elegoo_spaghetti_detection_confidence
sensor.elegoo_spaghetti_detection_raw_score
sensor.elegoo_spaghetti_detection_detections
binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
button.elegoo_spaghetti_detection_test_spaghetti_detection
button.elegoo_spaghetti_detection_reset_detection_state
```
The enhanced example also references this placeholder automation:
```text
automation.elegoo_cc2_spaghetti_pause_and_notify
```
Replace it with your own notification or pause automation entity.
The toggle card in `dashboard_hacs.yaml` is only a placeholder until you make
that replacement.
## Status Labels
The dashboard examples render raw detector states as user-facing English labels:
| Raw state | Dashboard label |
| --- | --- |
| `clear` | Clear |
| `detected` | Failure detected |
| `warning` | Warning |
| `checking` | Checking |
| `waiting_for_print` | Waiting for print |
| `status_unavailable` | Print status unavailable |
| `busy` | Busy |
| `error` | Error |
| `idle` | Idle |
This avoids a mixed display where Home Assistant shows an unknown printer state
while the detector correctly reports `waiting_for_print`.
## Core Home Assistant Example
Use this when you do not want extra Lovelace dependencies:
- [examples/dashboard_core.yaml](../examples/dashboard_core.yaml)
It uses only built-in cards:
- `picture-entity`
- `markdown`
- `gauge`
- `conditional`
- `button`
- `entities`
## Enhanced HACS Example
Use this when custom Lovelace cards are allowed:
- [examples/dashboard_hacs.yaml](../examples/dashboard_hacs.yaml)
Idle state:
![Enhanced dashboard idle state](images/dashboard-hacs-waiting-for-print.png)
Detected failure state:
![Enhanced dashboard detected failure](images/dashboard-hacs-detected.png)
Recommended custom cards:
- `custom:button-card`
- `custom:mushroom-template-card`
- `card_mod`
Install those through HACS before pasting the enhanced YAML. The enhanced
version adds a compact status panel, better visual states, responsive metric
tiles, and an alert card when spaghetti is detected.
## Next Scheduled Check
The integration exposes:
```text
sensor.<prefix>_next_run
```
For the default prefix this is:
```text
sensor.elegoo_spaghetti_detection_next_run
```
The value is updated when the integration starts and every time the scheduled
interval fires. It represents the next scheduled interval tick. If the print
status is not active, the detector still waits at that tick and keeps the status
as `waiting_for_print`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+43
View File
@@ -0,0 +1,43 @@
# Installation
## HACS Custom Repository
This repository is a HACS `integration`. It is not a dashboard card or Lovelace
plugin.
Until it is accepted into the default HACS store, add it manually:
1. Open Home Assistant.
2. Open `HACS`.
3. Open the three-dot menu.
4. Choose `Custom repositories`.
5. Repository:
```text
https://github.com/hepter/ha-elegoo-spaghetti-detection
```
6. Category: `Integration`.
7. Install `Elegoo Spaghetti Detection`.
8. Restart Home Assistant.
## Manual Install
Copy:
```text
custom_components/elegoo_spaghetti_detection
```
to:
```text
/config/custom_components/elegoo_spaghetti_detection
```
Restart Home Assistant.
## ML Server
The integration needs the local ML server before setup can complete. See
[ML server and logs](ml-server.md).
+96
View File
@@ -0,0 +1,96 @@
# ML Server And Logs
The ML server listens on port `3333` and exposes the Obico/TSD model used for
failure detection.
## Standalone Docker Compose
```bash
git clone https://github.com/hepter/ha-elegoo-spaghetti-detection.git
cd ha-elegoo-spaghetti-detection
docker compose up -d
```
Default URL:
```text
http://<server-ip>:3333
```
Default token:
```text
obico_api_secret
```
Change `ML_API_TOKEN` before exposing this service outside a trusted local
network.
## Runtime Endpoints
| Endpoint | Auth | Purpose |
| --- | --- | --- |
| `/` | no | Small browser status page and recent redacted requests. |
| `/hc/` | no | Health check, returns `ok`. |
| `/api/status` | no | JSON status, model backend, threshold, request count. |
| `/api/logs?token=<token>` | yes | Recent request logs. Image query tokens are not shown in the dashboard. |
| `/debug/image?img=<url>&token=<token>` | yes | Fetch and decode a camera image without running inference. Used by setup validation. |
| `/p/?img=<url>` | yes | Prediction endpoint used by Home Assistant. |
The token can be passed as either:
```text
Authorization: Bearer <token>
```
or, for browser debugging only:
```text
?token=<token>
```
## CPU/GPU Behavior
The server is CPU-first by default:
```text
ML_USE_GPU=false
ML_MODEL_BACKEND=onnx
GUNICORN_TIMEOUT=120
GUNICORN_WORKERS=1
```
This avoids slow CUDA probing and gunicorn worker timeouts on machines without a
working NVIDIA runtime. Enable GPU only when Docker has working NVIDIA support:
```text
ML_USE_GPU=true
```
## Logs
Docker:
```bash
docker logs -f ha_elegoo_spaghetti_detection
```
Recent in-app request log:
```bash
curl "http://<server-ip>:3333/api/logs?token=obico_api_secret"
```
Health and model backend:
```bash
curl "http://<server-ip>:3333/api/status"
```
When testing a camera URL by hand, URL-encode the image URL:
```bash
curl --get "http://<server-ip>:3333/debug/image" \
--data-urlencode "img=http://homeassistant.local:8123/api/camera_proxy/camera.example?token=..." \
--data-urlencode "token=obico_api_secret"
```
+145
View File
@@ -0,0 +1,145 @@
# Troubleshooting
## Setup Fails On ML Health
Use the ML server base URL, not a specific endpoint:
```text
http://192.168.1.100:3333
```
Do not enter:
```text
http://192.168.1.100:3333/hc/
http://192.168.1.100:3333/p/
```
Check:
```bash
curl "http://192.168.1.100:3333/hc/"
```
The browser dashboard is also useful:
```text
http://192.168.1.100:3333/
```
## Setup Fails On Camera Image Fetch
The ML server must be able to fetch the Home Assistant camera image URL.
Common cause:
```text
http://homeassistant.local:8123
```
works from a browser but not from a Docker container because mDNS is not
resolved there.
Use a LAN IP URL reachable by the ML server:
```text
http://192.168.1.90:8123
```
You can test only image fetch/decode without running the model:
```bash
curl --get "http://192.168.1.100:3333/debug/image" \
--data-urlencode "img=http://192.168.1.90:8123/api/camera_proxy/camera.example?token=..." \
--data-urlencode "token=obico_api_secret"
```
## Camera Entity Does Not Provide An Image
Some camera integrations expose stream-only entities or changed entity IDs after
updates. Set `Direct snapshot URL` if the selected camera has no `entity_picture`
attribute, or update the detector options after the camera entity ID changes.
## Multiple Detectors
Each config entry has a separate detector runtime and entity prefix. The Home
Assistant side supports multiple cameras.
The ML server is intentionally single-worker by default. The integration
serializes ML calls so multiple detectors do not hit the single worker at the
same instant. If you have a stronger host and many cameras, increase
`GUNICORN_WORKERS` carefully.
## CUDA Or Worker Timeout
The server defaults to CPU mode:
```text
ML_USE_GPU=false
GUNICORN_TIMEOUT=120
```
Only enable GPU when Docker has a working NVIDIA runtime. If you see CUDA driver
errors, keep GPU disabled.
## Detection Looks Too Quiet
Open the ML server dashboard:
```text
http://<server-ip>:3333/
```
Or check recent request logs:
```bash
curl "http://<server-ip>:3333/api/logs?token=obico_api_secret"
```
You can also press the `Test Spaghetti Detection` button in Home Assistant.
## Scheduled Detection Runs While Printer Is Idle
Scheduled detection is gated by `Print status sensor` unless
`Run scheduled detection without print status` is enabled.
Check these options first:
```text
Print status sensor: sensor.elegoo_centauri_carbon2_print_status
Active print states: printing
Run scheduled detection without print status: off
```
If the status sensor is `idle`, `complete`, `paused`, `unknown`, or
`unavailable`, the scheduled interval should wait and the detector status should
show `waiting_for_print` or `status_unavailable`.
For Elegoo-style entity names, a selected
`sensor.<printer>_print_status` automatically uses
`sensor.<printer>_current_status` as a second guard when that entity exists. If
`print_status` is `printing` but `current_status` is `idle`, `homing`, or
another non-active state, scheduled detection waits and `last_run` does not
advance.
The `Test Spaghetti Detection` button and the `run_detection` service with
`force: true` always run one manual check, even when the printer is not
printing.
## Notifications Repeat Every Interval
Notification, pause, and stop automations should trigger on:
```text
elegoo_spaghetti_detection_detected
```
Do not use this event for normal notifications:
```text
elegoo_spaghetti_detection_result
```
The result event fires after every completed detection check, including repeated
detected checks. The detected event is limited to one scheduled failure event
per active print window when a print status sensor is configured.
+77
View File
@@ -0,0 +1,77 @@
alias: Elegoo Spaghetti - Actionable Notification
mode: single
triggers:
- trigger: event
event_type: elegoo_spaghetti_detection_detected
event_data:
detector: elegoo_spaghetti_detection
id: detected
- trigger: event
event_type: mobile_app_notification_action
event_data:
action: ELEGOO_SPAGHETTI_PAUSE_PRINT
id: pause_action
- trigger: event
event_type: mobile_app_notification_action
event_data:
action: ELEGOO_SPAGHETTI_STOP_PRINT
id: stop_action
- trigger: event
event_type: mobile_app_notification_action
event_data:
action: ELEGOO_SPAGHETTI_RESUME_PRINT
id: resume_action
variables:
notify_service: notify.mobile_app_your_phone
pause_button: button.elegoo_centauri_carbon2_pause_print
resume_button: button.elegoo_centauri_carbon2_resume_print
stop_button: button.elegoo_centauri_carbon2_stop_print
actions:
- choose:
- conditions:
- condition: trigger
id: detected
sequence:
- action: "{{ notify_service }}"
data:
title: "Possible print failure"
message: >
Confidence:
{{ (trigger.event.data.confidence | float(0) * 100) | round(1) }}%.
Detections: {{ trigger.event.data.detections }}.
data:
image: "{{ trigger.event.data.image_url }}"
actions:
- action: ELEGOO_SPAGHETTI_PAUSE_PRINT
title: Pause print
- action: ELEGOO_SPAGHETTI_STOP_PRINT
title: Stop print
- action: ELEGOO_SPAGHETTI_RESUME_PRINT
title: Resume print
- conditions:
- condition: trigger
id: pause_action
sequence:
- action: button.press
target:
entity_id: "{{ pause_button }}"
- conditions:
- condition: trigger
id: stop_action
sequence:
- action: button.press
target:
entity_id: "{{ stop_button }}"
- conditions:
- condition: trigger
id: resume_action
sequence:
- action: button.press
target:
entity_id: "{{ resume_button }}"
+116
View File
@@ -0,0 +1,116 @@
type: vertical-stack
cards:
- type: picture-entity
entity: camera.elegoo_centauri_carbon2_chamber_camera
name: Printer camera
camera_view: live
show_state: false
- type: markdown
title: Spaghetti Detection
content: |
{% set status = states('sensor.elegoo_spaghetti_detection_status') %}
{% set confidence = states('sensor.elegoo_spaghetti_detection_confidence') | float(0) %}
{% set detections = states('sensor.elegoo_spaghetti_detection_detections') %}
{% set print_status = states('sensor.elegoo_centauri_carbon2_print_status') %}
{% set next_run = states('sensor.elegoo_spaghetti_detection_next_run') %}
{% set last_run = states('sensor.elegoo_spaghetti_detection_last_run') %}
{% set last_error = states('sensor.elegoo_spaghetti_detection_last_error') %}
{% if status == 'detected' %}
## Failure detected
The detector is above the failure threshold.
{% elif status == 'warning' %}
## Warning
The detector is above the warning threshold.
{% elif status == 'checking' %}
## Checking camera image
The current image is being analyzed by the ML server.
{% elif status == 'waiting_for_print' %}
## Waiting for print
Scheduled checks are paused because the selected print status is not active.
{% elif status == 'status_unavailable' %}
## Print status unavailable
The selected print status entity is missing, unknown, or unavailable.
{% elif status == 'clear' %}
## Clear
No spaghetti was detected in the last check.
{% else %}
## {{ status | replace('_', ' ') | title }}
The detector state is currently {{ status }}.
{% endif %}
**Confidence:** {{ confidence | round(1) }}%
**Detections:** {{ detections }}
**Print status:** {{ print_status }}
**Next scheduled check:** {% if next_run in ['unknown', 'unavailable', 'none', ''] %}Not scheduled yet{% else %}{{ as_timestamp(next_run) | timestamp_custom('%Y-%m-%d %H:%M:%S', true) }}{% endif %}
**Last check:** {% if last_run in ['unknown', 'unavailable', 'none', ''] %}Never{% else %}{{ as_timestamp(last_run) | timestamp_custom('%Y-%m-%d %H:%M:%S', true) }}{% endif %}
**Last error:** {% if last_error in ['none', 'unknown', 'unavailable', ''] %}None{% else %}{{ last_error }}{% endif %}
- type: gauge
entity: sensor.elegoo_spaghetti_detection_confidence
name: Confidence
min: 0
max: 100
needle: true
severity:
green: 0
yellow: 30
red: 50
- type: conditional
conditions:
- entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
state: "on"
card:
type: markdown
title: Action needed
content: |
Spaghetti was detected.
Check the printer camera before resuming or stopping the print.
- type: grid
columns: 2
square: false
cards:
- type: button
entity: button.elegoo_spaghetti_detection_test_spaghetti_detection
name: Test detection
icon: mdi:camera-iris
tap_action:
action: perform-action
perform_action: button.press
target:
entity_id: button.elegoo_spaghetti_detection_test_spaghetti_detection
- type: button
entity: button.elegoo_spaghetti_detection_reset_detection_state
name: Reset detector
icon: mdi:restart
tap_action:
action: perform-action
perform_action: button.press
target:
entity_id: button.elegoo_spaghetti_detection_reset_detection_state
- type: entities
title: Detector details
show_header_toggle: false
entities:
- entity: sensor.elegoo_spaghetti_detection_status
name: Detector status
- entity: sensor.elegoo_spaghetti_detection_next_run
name: Next scheduled check
- entity: sensor.elegoo_spaghetti_detection_last_run
name: Last check
- entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
name: Spaghetti detected
- entity: sensor.elegoo_spaghetti_detection_confidence
name: Confidence
- entity: sensor.elegoo_spaghetti_detection_raw_score
name: Raw score
- entity: sensor.elegoo_spaghetti_detection_detections
name: Detection count
- entity: sensor.elegoo_spaghetti_detection_last_error
name: Last error
+492
View File
@@ -0,0 +1,492 @@
type: vertical-stack
cards:
- type: custom:button-card
entity: sensor.elegoo_spaghetti_detection_status
show_name: false
show_icon: false
show_state: false
tap_action:
action: more-info
custom_fields:
content: |
[[[
const state = (id) => states[id]?.state ?? 'unknown';
const numberState = (id) => Number.parseFloat(state(id)) || 0;
const formatDate = (value, fallback = 'Not available') => {
if (!value || ['unknown', 'unavailable', 'none'].includes(value)) return fallback;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const status = state('sensor.elegoo_spaghetti_detection_status').trim();
const detected = state('binary_sensor.elegoo_spaghetti_detection_spaghetti_detected') === 'on';
const confidence = numberState('sensor.elegoo_spaghetti_detection_confidence');
const raw = numberState('sensor.elegoo_spaghetti_detection_raw_score');
const detections = state('sensor.elegoo_spaghetti_detection_detections');
const printStatus = state('sensor.elegoo_centauri_carbon2_print_status');
const nextRun = state('sensor.elegoo_spaghetti_detection_next_run');
const lastRun = state('sensor.elegoo_spaghetti_detection_last_run');
const lastError = state('sensor.elegoo_spaghetti_detection_last_error');
const automation = state('automation.elegoo_cc2_spaghetti_pause_and_notify');
const statusMap = {
clear: {
label: 'Clear',
color: '#34c759',
bg: 'rgba(52,199,89,.14)',
desc: 'No spaghetti was detected in the last check.'
},
detected: {
label: 'Failure detected',
color: '#ff453a',
bg: 'rgba(255,69,58,.18)',
desc: 'Check the camera before continuing.'
},
warning: {
label: 'Warning',
color: '#ff9f0a',
bg: 'rgba(255,159,10,.16)',
desc: 'The score is above the warning threshold but below failure.'
},
checking: {
label: 'Checking',
color: '#0a84ff',
bg: 'rgba(10,132,255,.16)',
desc: 'The current camera image is being analyzed.'
},
waiting_for_print: {
label: 'Waiting for print',
color: '#8e8e93',
bg: 'rgba(142,142,147,.14)',
desc: 'Waiting for an active print.'
},
status_unavailable: {
label: 'Print status unavailable',
color: '#ff9f0a',
bg: 'rgba(255,159,10,.14)',
desc: 'Check the selected print status entity in integration options.'
},
busy: {
label: 'Busy',
color: '#0a84ff',
bg: 'rgba(10,132,255,.14)',
desc: 'Another detection request is still running.'
},
error: {
label: 'Error',
color: '#ff453a',
bg: 'rgba(255,69,58,.16)',
desc: 'The last run failed. Check the error field below.'
},
idle: {
label: 'Idle',
color: '#8e8e93',
bg: 'rgba(142,142,147,.14)',
desc: 'The detector is idle.'
}
};
const view = statusMap[status] || {
label: status.replaceAll('_', ' '),
color: '#8e8e93',
bg: 'rgba(142,142,147,.14)',
desc: 'The detector state is currently unknown.'
};
const automationColor = automation === 'on' ? '#34c759' : automation === 'off' ? '#ff9f0a' : '#8e8e93';
const automationText = automation === 'on'
? 'Automation enabled'
: automation === 'off'
? 'Automation disabled'
: 'Automation not found';
const errorText =
lastError && !['none', 'unknown', 'unavailable', ''].includes(lastError)
? lastError
: 'None';
return `
<div class="wrap">
<div class="header">
<div class="status-dot" style="background:${view.color}; box-shadow:0 0 24px ${view.color}66;"></div>
<div class="header-main">
<div class="title">Spaghetti Detection</div>
<div class="subtitle">${view.label}</div>
</div>
<div class="automation-pill" style="border-color:${automationColor}55; background:${automationColor}18; color:${automationColor};">
${automationText}
</div>
</div>
<div class="desc" style="background:${view.bg}; border-color:${view.color}33;">
${view.desc}
</div>
<div class="confidence">
<div class="confidence-top">
<span>Confidence</span>
<b>${confidence.toFixed(1)}%</b>
</div>
<div class="bar">
<div class="bar-fill" style="
width:${Math.max(0, Math.min(100, confidence))}%;
background:${detected ? '#ff453a' : view.color};
box-shadow:0 0 18px ${detected ? '#ff453a' : view.color};
"></div>
</div>
</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">Print</div>
<div class="metric-value">${printStatus}</div>
</div>
<div class="metric">
<div class="metric-label">Next check</div>
<div class="metric-value">${formatDate(nextRun, 'Not scheduled')}</div>
</div>
<div class="metric">
<div class="metric-label">Detections</div>
<div class="metric-value">${detections}</div>
</div>
<div class="metric">
<div class="metric-label">Raw score</div>
<div class="metric-value">${raw.toFixed(3)}</div>
</div>
</div>
<div class="footer">
<div><b>Last check:</b> ${formatDate(lastRun, 'Never')}</div>
<div><b>Last error:</b> ${errorText}</div>
</div>
</div>
`;
]]]
styles:
card:
- border-radius: 22px
- padding: 14px
- background: rgba(255,255,255,.04)
- border: 1px solid rgba(255,255,255,.08)
- box-shadow: none
- overflow: hidden
grid:
- grid-template-areas: "\"content\""
- grid-template-columns: 1fr
- grid-template-rows: auto
custom_fields:
content:
- width: 100%
- justify-self: stretch
- text-align: left
extra_styles: |
.wrap {
width: 100%;
box-sizing: border-box;
}
.header {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
}
.status-dot {
width: 18px;
height: 18px;
border-radius: 999px;
}
.title {
font-size: 21px;
font-weight: 800;
color: rgba(255,255,255,.96);
line-height: 1.15;
}
.subtitle {
margin-top: 3px;
font-size: 14px;
opacity: .74;
font-weight: 700;
text-transform: capitalize;
}
.automation-pill {
border: 1px solid;
border-radius: 999px;
padding: 7px 10px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.desc {
margin-top: 14px;
padding: 11px 12px;
border: 1px solid;
border-radius: 14px;
color: rgba(255,255,255,.82);
font-size: 13px;
line-height: 1.35;
white-space: normal;
overflow-wrap: anywhere;
}
.confidence {
margin-top: 14px;
}
.confidence-top {
display: flex;
justify-content: space-between;
margin-bottom: 7px;
font-size: 13px;
opacity: .85;
}
.bar {
height: 12px;
border-radius: 999px;
background: rgba(255,255,255,.11);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
transition: width .3s ease;
}
.metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.metric {
border-radius: 14px;
padding: 10px;
background: rgba(255,255,255,.06);
text-align: center;
min-width: 0;
}
.metric-label {
opacity: .62;
font-size: 12px;
font-weight: 700;
}
.metric-value {
margin-top: 4px;
font-size: 14px;
font-weight: 800;
color: rgba(255,255,255,.96);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer {
margin-top: 13px;
font-size: 12px;
opacity: .70;
line-height: 1.45;
}
@media (max-width: 520px) {
.header {
grid-template-columns: auto 1fr;
}
.automation-pill {
grid-column: 1 / -1;
justify-self: start;
}
.metrics {
grid-template-columns: 1fr 1fr;
}
}
- type: conditional
conditions:
- entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
state: "on"
card:
type: custom:mushroom-template-card
entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
primary: Spaghetti detected
secondary: >-
Confidence: {{ states('sensor.elegoo_spaghetti_detection_confidence') }}%
• Detections: {{ states('sensor.elegoo_spaghetti_detection_detections') }}
icon: mdi:alert-octagon
icon_color: red
badge_icon: mdi:pause
badge_color: red
layout: horizontal
tap_action:
action: more-info
card_mod:
style: |
ha-card {
border-radius: 18px;
background: rgba(255,69,58,.16);
border: 1px solid rgba(255,69,58,.34);
animation: spaghettiPulse 1.6s ease-in-out infinite;
}
@keyframes spaghettiPulse {
0% { box-shadow: 0 0 0 rgba(255,69,58,0); }
50% { box-shadow: 0 0 28px rgba(255,69,58,.30); }
100% { box-shadow: 0 0 0 rgba(255,69,58,0); }
}
- type: grid
columns: 2
square: false
cards:
# Replace this placeholder automation with your own notification/pause
# automation entity before using the toggle card below.
- type: custom:mushroom-template-card
entity: automation.elegoo_cc2_spaghetti_pause_and_notify
primary: Auto pause
secondary: >-
{% if is_state('automation.elegoo_cc2_spaghetti_pause_and_notify', 'on') %}
Enabled: pause and notify on failure
{% elif is_state('automation.elegoo_cc2_spaghetti_pause_and_notify', 'off') %}
Disabled: notification-only or manual review mode
{% else %}
Replace this entity with your own automation
{% endif %}
icon: >-
{% if is_state('automation.elegoo_cc2_spaghetti_pause_and_notify', 'on') %}
mdi:shield-check
{% else %}
mdi:shield-off
{% endif %}
icon_color: >-
{% if is_state('automation.elegoo_cc2_spaghetti_pause_and_notify', 'on') %}
green
{% else %}
amber
{% endif %}
layout: vertical
tap_action:
action: toggle
hold_action:
action: more-info
card_mod:
style: |
ha-card {
border-radius: 18px;
background: rgba(255,255,255,.045);
border: 1px solid rgba(255,255,255,.08);
}
- type: custom:mushroom-template-card
entity: button.elegoo_spaghetti_detection_test_spaghetti_detection
primary: Manual test
secondary: Take one snapshot and run detection
icon: mdi:camera-iris
icon_color: blue
layout: vertical
tap_action:
action: perform-action
perform_action: button.press
target:
entity_id: button.elegoo_spaghetti_detection_test_spaghetti_detection
card_mod:
style: |
ha-card {
border-radius: 18px;
background: rgba(10,132,255,.10);
border: 1px solid rgba(10,132,255,.24);
}
- type: custom:mushroom-template-card
entity: button.elegoo_spaghetti_detection_reset_detection_state
primary: Reset detector
secondary: Clear confidence and detected state
icon: mdi:restart
icon_color: purple
layout: vertical
tap_action:
action: perform-action
perform_action: button.press
target:
entity_id: button.elegoo_spaghetti_detection_reset_detection_state
card_mod:
style: |
ha-card {
border-radius: 18px;
background: rgba(175,82,222,.10);
border: 1px solid rgba(175,82,222,.24);
}
- type: custom:mushroom-template-card
entity: sensor.elegoo_spaghetti_detection_last_error
primary: Last error
secondary: >-
{% set e = states('sensor.elegoo_spaghetti_detection_last_error') %}
{% if e in ['none', 'unknown', 'unavailable', ''] %}
None
{% else %}
{{ e }}
{% endif %}
icon: >-
{% set e = states('sensor.elegoo_spaghetti_detection_last_error') %}
{% if e in ['none', 'unknown', 'unavailable', ''] %}
mdi:check-circle
{% else %}
mdi:alert-circle
{% endif %}
icon_color: >-
{% set e = states('sensor.elegoo_spaghetti_detection_last_error') %}
{% if e in ['none', 'unknown', 'unavailable', ''] %}
green
{% else %}
red
{% endif %}
layout: vertical
multiline_secondary: true
tap_action:
action: more-info
card_mod:
style: |
ha-card {
border-radius: 18px;
background: rgba(255,255,255,.045);
border: 1px solid rgba(255,255,255,.08);
}
- type: entities
title: Spaghetti detector details
show_header_toggle: false
entities:
- entity: sensor.elegoo_spaghetti_detection_status
name: Status
- entity: sensor.elegoo_spaghetti_detection_next_run
name: Next scheduled check
- entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
name: Detected
- entity: sensor.elegoo_spaghetti_detection_confidence
name: Confidence
- entity: sensor.elegoo_spaghetti_detection_raw_score
name: Raw score
- entity: sensor.elegoo_spaghetti_detection_detections
name: Detection count
- entity: sensor.elegoo_spaghetti_detection_last_run
name: Last check
- entity: automation.elegoo_cc2_spaghetti_pause_and_notify
name: Auto pause automation
card_mod:
style: |
ha-card {
border-radius: 18px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.08);
}
+25
View File
@@ -0,0 +1,25 @@
alias: Elegoo Spaghetti - Manual Test Result Notification
mode: single
triggers:
- trigger: event
event_type: elegoo_spaghetti_detection_result
event_data:
detector: elegoo_spaghetti_detection
manual: true
variables:
notify_service: notify.mobile_app_your_phone
actions:
- action: "{{ notify_service }}"
data:
title: "Spaghetti detection test result"
message: >
Status: {{ trigger.event.data.status }}.
Confidence:
{{ (trigger.event.data.confidence | float(0) * 100) | round(1) }}%.
Detections: {{ trigger.event.data.detections }}.
Error: {{ trigger.event.data.last_error or 'none' }}.
data:
image: "{{ trigger.event.data.image_url }}"
+23
View File
@@ -0,0 +1,23 @@
alias: Elegoo Spaghetti - Notify Only
mode: single
triggers:
- trigger: event
event_type: elegoo_spaghetti_detection_detected
event_data:
detector: elegoo_spaghetti_detection
variables:
notify_service: notify.mobile_app_your_phone
actions:
- action: "{{ notify_service }}"
data:
title: "Possible print failure"
message: >
{{ trigger.event.data.name }} detected a possible spaghetti failure.
Confidence:
{{ (trigger.event.data.confidence | float(0) * 100) | round(1) }}%.
Detections: {{ trigger.event.data.detections }}.
data:
image: "{{ trigger.event.data.image_url }}"
@@ -0,0 +1,50 @@
alias: Elegoo Spaghetti - Smart Pause Stop
mode: single
triggers:
- trigger: event
event_type: elegoo_spaghetti_detection_detected
event_data:
detector: elegoo_spaghetti_detection
variables:
notify_service: notify.mobile_app_your_phone
pause_button: button.elegoo_centauri_carbon2_pause_print
stop_button: button.elegoo_centauri_carbon2_stop_print
confidence: "{{ trigger.event.data.confidence | float(0) }}"
confidence_percent: "{{ (confidence | float(0) * 100) | round(1) }}"
detections: "{{ trigger.event.data.detections | int(0) }}"
actions:
- choose:
- conditions:
- condition: template
value_template: "{{ confidence >= 0.85 }}"
sequence:
- action: "{{ notify_service }}"
data:
title: "High confidence print failure"
message: >
Confidence {{ confidence_percent }}%, detections {{ detections }}.
Stopping the print.
data:
image: "{{ trigger.event.data.image_url }}"
- action: button.press
target:
entity_id: "{{ stop_button }}"
- conditions:
- condition: template
value_template: "{{ confidence < 0.85 }}"
sequence:
- action: "{{ notify_service }}"
data:
title: "Print paused for review"
message: >
Confidence {{ confidence_percent }}%, detections {{ detections }}.
Pausing the print for manual review.
data:
image: "{{ trigger.event.data.image_url }}"
- action: button.press
target:
entity_id: "{{ pause_button }}"
+4
View File
@@ -0,0 +1,4 @@
{
"name": "Elegoo Spaghetti Detection",
"homeassistant": "2026.4.0"
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Elegoo Spaghetti Detection",
"url": "https://github.com/hepter/ha-elegoo-spaghetti-detection",
"maintainer": "hepter"
}