PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 All 169 releases
← All changes | includes/API/V1/Print_Jobs_Controller.php +559 -37 1.10.19 → 1.10.17 View file →
@@ -7,14 +7,16 @@
7 7
8 8 namespace WCPOS\WooCommercePOS\API\V1;
9 9
10 10 use WCPOS\WooCommercePOS\Logger;
11 -use WCPOS\WooCommercePOS\Interfaces\Push_Provider_Adapter_Interface;
12 -use WCPOS\WooCommercePOS\Services\Print_Job_Lifecycle;
11 +use WCPOS\WooCommercePOS\Services\Cloud_Print_Diagnostic;
12 +use WCPOS\WooCommercePOS\Services\Cloud_Print_Media_Types;
13 +use WCPOS\WooCommercePOS\Services\Cloud_Print_Poll_Request;
13 14 use WCPOS\WooCommercePOS\Services\Cloud_Print_Relay_Service;
14 15 use WCPOS\WooCommercePOS\Services\Cloud_Print_Registry;
15 16 use WCPOS\WooCommercePOS\Services\Cloud_Print_Trigger_Service;
16 17 use WCPOS\WooCommercePOS\Services\PrintNode_Client;
18 +use WCPOS\WooCommercePOS\Services\Print_Format_Resolver;
17 19 use WCPOS\WooCommercePOS\Services\Print_Job_Service;
18 20 use WCPOS\WooCommercePOS\Services\Provider;
19 21 use WCPOS\WooCommercePOS\Services\Star_Online_Client;
20 22 use WP_Error;
@@ -29,13 +31,19 @@
29 31 * Print_Jobs_Controller class.
30 32 */
31 33 class Print_Jobs_Controller extends WP_REST_Controller {
32 34 /**
33 - * Legacy Epson timeout.
35 + * Milliseconds an Epson Server Direct Print printer waits for the local
36 + * print device to become printable before it gives up and reports
37 + * EX_TIMEOUT.
34 38 *
35 - * @deprecated Use Epson_Sdp_Adapter::EPSON_SDP_PRINT_TIMEOUT_MS.
39 + * Epson allows 5000–300000. The previous 10000 (near the floor) was
40 + * unforgiving for printers that briefly go not-ready — a paper change, a
41 + * sleep/wake, or a momentary network blip — declaring the job failed
42 + * before the printer could recover. 60000 gives those transient states
43 + * room to clear without letting a genuinely offline printer hang for long.
36 44 */
37 - const EPSON_SDP_PRINT_TIMEOUT_MS = \WCPOS\WooCommercePOS\Services\Providers\Epson_Sdp_Adapter::EPSON_SDP_PRINT_TIMEOUT_MS;
45 + const EPSON_SDP_PRINT_TIMEOUT_MS = 60000;
38 46
39 47 /**
40 48 * Endpoint namespace.
41 49 *
@@ -764,15 +772,16 @@
764 772 );
765 773 }
766 774 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
767 775 if ( null !== $printer ) {
768 - // Provider::format() refreshes both halves together. A legacy job
769 - // can carry a media type from before the provider declared its own,
776 + // Refresh both halves of the pairing together. A legacy job can
777 + // carry a media type from before the provider declared its own,
770 778 // but content_type and pn_kind must keep agreeing: reprinting a
771 - // raw (escpos) PrintNode job through printer_content_type()
779 + // raw (escpos) PrintNode job through the printer-only resolver
772 780 // relabels it application/pdf in the queue view, even though
773 781 // submit still sends raw bytes off the stored pn_kind.
774 - $fmt = null === $template ? array( 'kind' => '' ) : Provider::format( $printer, $template );
782 + $resolver = new Print_Format_Resolver();
783 + $fmt = null === $template ? array( 'kind' => '' ) : $resolver->resolve( $printer, $template );
775 784 if ( '' === (string) $fmt['kind'] ) {
776 785 // No loadable template, or one this printer can no longer
777 786 // render. Refresh from the provider's declared type only
778 787 // when no stored kind can contradict it; otherwise the
@@ -777,9 +786,9 @@
777 786 // render. Refresh from the provider's declared type only
778 787 // when no stored kind can contradict it; otherwise the
779 788 // source pairing is the best answer left.
780 789 if ( '' === $pn_kind ) {
781 - $content_type = Provider::printer_content_type( $printer );
790 + $content_type = $resolver->content_type_for_printer( $printer );
782 791 }
783 792 } else {
784 793 $content_type = $fmt['content_type'];
785 794 $pn_kind = Provider::stores_job_kind( Provider::normalize( (string) ( $printer['provider'] ?? '' ) ) )
@@ -844,12 +853,206 @@
844 853 *
845 854 * @return \WP_REST_Response|WP_Error
846 855 */
847 856 public function cloudprnt( $request ) {
848 - return $this->handle_provider_poll( $request, 'star-cloudprnt' );
857 + $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
858 + $this->registry->record_seen( $printer_id );
859 + $this->jobs->release_stale_claims( $printer_id );
860 +
861 + if ( 'POST' === $request->get_method() ) {
862 + return $this->cloudprnt_poll( $request, $printer_id );
863 + }
864 +
865 + $job = $this->get_cloud_job_for_request( $request, $printer_id );
866 + if ( is_wp_error( $job ) ) {
867 + return $job;
868 + }
869 +
870 + if ( 'DELETE' === $request->get_method() ) {
871 + $code = sanitize_text_field( (string) $request->get_param( 'code' ) );
872 + $status = '' === $code || '000' === $code || 1 === preg_match( '/^2\d{2,3}(?:\s|$)/', $code ) ? Print_Job_Service::STATUS_PRINTED : Print_Job_Service::STATUS_FAILED;
873 + $this->jobs->record_printer_result( (int) $job['id'], Print_Job_Service::STATUS_PRINTED === $status );
874 +
875 + if ( Print_Job_Service::STATUS_FAILED === $status ) {
876 + $this->log_printer_failure( $request, $printer_id, $code, (int) $job['id'] );
877 + }
878 +
879 + return rest_ensure_response( array( 'ok' => true ) );
880 + }
881 +
882 + return $this->cloudprnt_fetch( $request, $printer_id, $job );
849 883 }
850 884
851 885 /**
886 + * Answer a CloudPRNT poll: offer a job, and ask what the printer can decode.
887 + *
888 + * The poll body is the printer's half of the conversation. `printingInProgress`
889 + * says a job is still on the paper, and the spec is explicit that the server
890 + * must not offer another one until it clears — doing so risks the printer
891 + * dropping the second job. `clientAction` carries the printer's answers to
892 + * questions asked in an earlier poll response, which is the only way the
893 + * protocol exposes what formats the hardware can decode.
894 + *
895 + * @param WP_REST_Request $request Request.
896 + * @param string $printer_id Printer ID.
897 + *
898 + * @return \WP_REST_Response
899 + */
900 + private function cloudprnt_poll( WP_REST_Request $request, string $printer_id ) {
901 + $poll = Cloud_Print_Poll_Request::from_body( (string) $request->get_body(), $request->get_json_params() );
902 + $this->registry->record_capabilities( $printer_id, $poll->answers(), $poll->status_code() );
903 +
904 + $response = array( 'jobReady' => false );
905 +
906 + if ( ! $poll->printing_in_progress() && ! $this->jobs->find_active_claim( $printer_id ) ) {
907 + $job = $this->jobs->next_pending( $printer_id );
908 + if ( null !== $job ) {
909 + // The offer is a list: the printer picks its preferred decodable
910 + // entry and names it in the fetch's `?type`. Nothing decodable in
911 + // the list means no GET at all, just a 510 confirmation — so the
912 + // list is filtered by what this printer said it can decode.
913 + // `mediaType` (singular) is kept for older firmware.
914 + $media_types = $this->media_types_for_job( $job, $printer_id );
915 + $response = array(
916 + 'jobReady' => true,
917 + 'jobToken' => (string) $job['id'],
918 + 'mediaType' => $media_types[0],
919 + 'mediaTypes' => array_values( $media_types ),
920 + );
921 + }
922 + }
923 +
924 + if ( $this->registry->should_request_capabilities( $printer_id ) ) {
925 + $response['clientAction'] = array(
926 + array( 'request' => 'ClientType' ),
927 + array( 'request' => 'Encodings' ),
928 + );
929 + $this->registry->record_capability_request( $printer_id );
930 + }
931 +
932 + return rest_ensure_response( $response );
933 + }
934 +
935 + /**
936 + * Serve a CloudPRNT job in the media type the printer asked for.
937 + *
938 + * @param WP_REST_Request $request Request.
939 + * @param string $printer_id Printer ID.
940 + * @param array $job Job array.
941 + *
942 + * @return \WP_REST_Response|WP_Error
943 + */
944 + private function cloudprnt_fetch( WP_REST_Request $request, string $printer_id, array $job ) {
945 + // The fetch GET names the printer's chosen media type. A type the server
946 + // cannot produce is answered with 415 (per the CloudPRNT spec) and the job
947 + // is left unclaimed. What is servable is deliberately wider than what the
948 + // poll advertised: the printer naming a type is a stronger signal than our
949 + // cached capability answer, so a capability update landing between the two
950 + // requests must not reject a format we had just offered. Firmware that
951 + // omits the parameter gets our best offer for this printer instead. The
952 + // logged value is length-capped: printers poll every few seconds, so a
953 + // wedged loop must not flood the log with unbounded input.
954 + $servable = ( new Cloud_Print_Media_Types() )->servable_for_job( $job, $this->registry->get_printer( $printer_id ) );
955 + $requested = sanitize_text_field( (string) $request->get_param( 'type' ) );
956 + $chosen = '' === $requested
957 + ? $this->media_types_for_job( $job, $printer_id )[0]
958 + : Cloud_Print_Media_Types::match( $requested, $servable );
959 +
960 + if ( '' === $chosen ) {
961 + Logger::warning(
962 + sprintf(
963 + '%s: printer "%s" requested media type "%s" for print job %d, which the server can only serve as %s.',
964 + $request->get_route(),
965 + $printer_id,
966 + substr( $requested, 0, 100 ),
967 + (int) $job['id'],
968 + implode( ', ', $servable )
969 + )
970 + );
971 +
972 + return new WP_Error(
973 + 'wcpos_print_job_incompatible_media_type',
974 + __( 'The print job is not available in the requested media type.', 'woocommerce-pos' ),
975 + array( 'status' => 415 )
976 + );
977 + }
978 +
979 + if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) {
980 + return rest_ensure_response( array( 'jobReady' => false ) );
981 + }
982 +
983 + $render = $this->jobs->render_job( $job, $chosen );
984 + if ( '' === $render['body'] ) {
985 + Logger::error(
986 + sprintf(
987 + '%s: print job %d rendered an empty payload for printer "%s".',
988 + $request->get_route(),
989 + (int) $job['id'],
990 + $printer_id
991 + )
992 + );
993 + }
994 +
995 + return $this->serve_raw( $render['body'], $chosen, self::control_headers( $chosen, $render ) );
996 + }
997 +
998 + /**
999 + * The media types a CloudPRNT job can be served in, best first.
1000 + *
1001 + * @param array $job Job array.
1002 + * @param string $printer_id Printer ID.
1003 + *
1004 + * @return array<int, string>
1005 + */
1006 + private function media_types_for_job( array $job, string $printer_id ): array {
1007 + $capabilities = $this->registry->get_capabilities( $printer_id );
1008 +
1009 + return ( new Cloud_Print_Media_Types() )->for_job(
1010 + $job,
1011 + $this->registry->get_printer( $printer_id ),
1012 + $capabilities['encodings']
1013 + );
1014 + }
1015 +
1016 + /**
1017 + * Peripheral-control headers for a job served in a command-free format.
1018 + *
1019 + * `text/plain` and images carry no cut or drawer commands, so CloudPRNT reads
1020 + * them off the fetch response instead. Command formats express both in-band
1021 + * and must not also be told to cut, or the receipt cuts twice.
1022 + *
1023 + * Both headers are always sent, `none` included. Omitting them leaves the
1024 + * decision to the printer's own defaults, which cut plain-text jobs — so a
1025 + * template that deliberately does not cut would cut anyway, and would behave
1026 + * differently in text than in StarPRNT. Saying `none` out loud keeps the two
1027 + * formats rendering the same receipt.
1028 + *
1029 + * @param string $media_type The media type being served.
1030 + * @param array $render Render result from Print_Job_Service::render_job().
1031 + *
1032 + * @return array<string, string>
1033 + */
1034 + private static function control_headers( string $media_type, array $render ): array {
1035 + if ( ! Cloud_Print_Media_Types::is_header_controlled( $media_type ) ) {
1036 + return array();
1037 + }
1038 +
1039 + $headers = array(
1040 + 'X-Star-Cut' => null === $render['cut'] ? 'none' : (string) $render['cut'],
1041 + 'X-Star-CashDrawer' => null === $render['drawer'] ? 'none' : (string) $render['drawer'],
1042 + );
1043 +
1044 + // The raster is already two-colour, so the printer's Floyd-Steinberg
1045 + // default would dither an image that has nothing left to dither —
1046 + // softening crisp black-on-white text into stipple.
1047 + if ( Cloud_Print_Media_Types::PNG === Cloud_Print_Media_Types::normalize( $media_type ) ) {
1048 + $headers['X-Star-ImageDitherPattern'] = 'none';
1049 + }
1050 +
1051 + return $headers;
1052 + }
1053 +
1054 + /**
852 1055 * Permission check for printer-token routes.
853 1056 *
854 1057 * @param WP_REST_Request $request Request.
855 1058 *
@@ -877,40 +1080,110 @@
877 1080
878 1081 return true;
879 1082 }
880 1083
1084 + /**
1085 + * Resolve and authorize a CloudPRNT job token.
1086 + *
1087 + * @param WP_REST_Request $request Request.
1088 + * @param string $printer_id Printer ID.
1089 + *
1090 + * @return array|WP_Error
1091 + */
1092 + private function get_cloud_job_for_request( WP_REST_Request $request, string $printer_id ) {
1093 + $job_id = (int) $request->get_param( 'token' );
1094 + $job = $this->jobs->get( $job_id );
1095 + if ( null === $job || $printer_id !== $job['printer_id'] ) {
1096 + Logger::warning(
1097 + sprintf(
1098 + '%s: print job "%d" was not found for printer "%s".',
1099 + $request->get_route(),
1100 + $job_id,
1101 + $printer_id
1102 + )
1103 + );
881 1104
1105 + return new WP_Error(
1106 + 'wcpos_print_job_not_found',
1107 + __( 'Print job not found.', 'woocommerce-pos' ),
1108 + array( 'status' => 404 )
1109 + );
1110 + }
882 1111
1112 + return $job;
1113 + }
883 1114
884 1115 /**
885 - * Parse the vendor request and serve the lifecycle's plain-data response.
1116 + * Log a printer-reported failure without request credentials or payloads.
886 1117 *
887 1118 * @param WP_REST_Request $request Request.
888 - * @param string $provider_key Protocol selected by the route.
889 - * @return WP_REST_Response|WP_Error
1119 + * @param string $printer_id Printer ID.
1120 + * @param string $code Failure code.
1121 + * @param int $job_id Print job ID.
1122 + * @param int|null $status Epson ePOS response status bitmask, when the result carried one.
890 1123 */
891 - private function handle_provider_poll( WP_REST_Request $request, string $provider_key ) {
892 - $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
893 - $printer = $this->registry->get_printer( $printer_id );
894 - if ( null === $printer ) {
895 - return new WP_Error( 'wcpos_print_job_invalid_token', __( 'Invalid printer token.', 'woocommerce-pos' ), array( 'status' => 401 ) );
1124 + private function log_printer_failure( WP_REST_Request $request, string $printer_id, string $code, int $job_id, ?int $status = null ): void {
1125 + // One detail string serves both the stored reason and the log line, and it
1126 + // keeps the raw hex even when no bit decodes, so an unmapped or
1127 + // model-specific status can still be read back from a screenshot.
1128 + $flags = null === $status ? '' : implode( ', ', self::describe_epson_status( $status ) );
1129 + $detail = null === $status ? '' : sprintf( ' (0x%08X%s)', $status, '' === $flags ? '' : ': ' . $flags );
1130 + $reason = $code . $detail;
1131 +
1132 + // Persist it too, not just log it. Push providers already record their
1133 + // submission error against the job; polling printers report theirs here,
1134 + // and without this the queue can only ever say "Failed" while the reason
1135 + // (EX_TIMEOUT, a Star status code) is buried in the log. The decoded
1136 + // Epson status bits are what turn "EX_TIMEOUT" into "cover open".
1137 + if ( '' !== $reason ) {
1138 + update_post_meta( $job_id, Print_Job_Service::META_ERROR, sanitize_text_field( $reason ) );
896 1139 }
897 - $poll = Provider::poll_adapter( $provider_key )->parse(
898 - array(
899 - 'params' => $request->get_params(),
900 - 'body' => (string) $request->get_body(),
901 - 'json' => 'POST' === $request->get_method() ? $request->get_json_params() : null,
902 - 'method' => $request->get_method(),
903 - 'route' => $request->get_route(),
1140 +
1141 + Logger::error(
1142 + sprintf(
1143 + '%s: printer "%s" reported failure code "%s"%s for print job %d.',
1144 + $request->get_route(),
1145 + $printer_id,
1146 + $code,
1147 + $detail,
1148 + $job_id
904 1149 )
905 1150 );
906 - $result = ( new Print_Job_Lifecycle( $this->jobs, $this->registry ) )->handle_poll( $provider_key, $printer, $poll );
907 - if ( \is_string( $result['body'] ) ) {
908 - $response = $this->serve_raw( $result['body'], $result['headers']['Content-Type'], $result['headers'] );
909 - $response->set_status( $result['status'] );
910 - return $response;
1151 + }
1152 +
1153 + /**
1154 + * Decode an Epson ePOS-Print response status bitmask.
1155 + *
1156 + * @param int $status Decimal ASB status bitmask.
1157 + *
1158 + * @return array<int, string>
1159 + */
1160 + private static function describe_epson_status( int $status ): array {
1161 + // Epson ePOS-Print XML User's Manual, response `status` table (ASB bits).
1162 + // Fault bits only: informational ones (print complete, drawer pin, feed
1163 + // button, panel switch, buzzer) say nothing about why a print failed.
1164 + $labels = array(
1165 + 0x00000001 => __( 'no response from printer', 'woocommerce-pos' ),
1166 + 0x00000008 => __( 'offline', 'woocommerce-pos' ),
1167 + 0x00000020 => __( 'cover open', 'woocommerce-pos' ),
1168 + 0x00000100 => __( 'waiting for online recovery', 'woocommerce-pos' ),
1169 + 0x00000400 => __( 'mechanical error', 'woocommerce-pos' ),
1170 + 0x00000800 => __( 'autocutter error', 'woocommerce-pos' ),
1171 + 0x00002000 => __( 'unrecoverable error', 'woocommerce-pos' ),
1172 + 0x00004000 => __( 'auto-recoverable error', 'woocommerce-pos' ),
1173 + 0x00020000 => __( 'paper near end', 'woocommerce-pos' ),
1174 + 0x00080000 => __( 'paper end', 'woocommerce-pos' ),
1175 + 0x80000000 => __( 'spooler stopped', 'woocommerce-pos' ),
1176 + );
1177 +
1178 + $descriptions = array();
1179 + foreach ( $labels as $bit => $label ) {
1180 + if ( 0 !== ( $status & $bit ) ) {
1181 + $descriptions[] = $label;
1182 + }
911 1183 }
912 - return new WP_REST_Response( $result['body'], $result['status'], $result['headers'] );
1184 +
1185 + return $descriptions;
913 1186 }
914 1187
915 1188 /**
916 1189 * Serve raw bytes from a REST callback.
@@ -933,9 +1206,155 @@
933 1206 *
934 1207 * @return \WP_REST_Response
935 1208 */
936 1209 public function epson_sdp( $request ) {
937 - return $this->handle_provider_poll( $request, 'epson-sdp' );
1210 + $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1211 + $this->registry->record_seen( $printer_id );
1212 + $raw_body = (string) $request->get_body();
1213 + $soap = 'text/xml; charset=utf-8';
1214 + $ack = '<response success="true" code="" status=""/>';
1215 +
1216 + $this->jobs->release_stale_claims( $printer_id );
1217 +
1218 + // Server Direct Print multiplexes three different request types onto the
1219 + // one configured URL, as URL-encoded form data distinguished by
1220 + // ConnectionType (User's Manual Rev.K, ch.3 and the Test_print.php
1221 + // reference implementation):
1222 + //
1223 + // GetRequest — poll for a job
1224 + // SetResponse — printing result; the XML rides in the ResponseFile field
1225 + // SetStatus — status notification; the XML rides in the Status field
1226 + //
1227 + // Answering a status notification with print data hands the job to a
1228 + // request that discards it, so the printer never prints and the job stays
1229 + // claimed. Dispatching on ConnectionType is what keeps the job on the
1230 + // GetRequest that is actually asking for one.
1231 + $connection_type = (string) $request->get_param( 'ConnectionType' );
1232 +
1233 + // Result XML is a form field, so in the raw body it is percent-encoded
1234 + // (`<response` arrives as `%3Cresponse`) and a raw-body substring test can
1235 + // never match it. The raw-body branch is kept only for a caller that posts
1236 + // the bare XML, which the printer never does.
1237 + $result_xml = (string) $request->get_param( 'ResponseFile' );
1238 + if ( '' === $result_xml && false !== strpos( $raw_body, '<response' ) ) {
1239 + $result_xml = $raw_body;
1240 + }
1241 +
1242 + // A print result is recognised by its ePOS <response> element, whichever
1243 + // way it arrived — the bare-XML caller sends no ConnectionType, so the
1244 + // type is deliberately not consulted here. The printer also posts an
1245 + // empty <PrintResponseInfo/> after every idle poll; reading that as a
1246 + // result marked the in-flight job failed ("unknown"). It is acked below
1247 + // and never dispatched on.
1248 + if ( false !== strpos( $result_xml, '<response' ) ) {
1249 + $claim = $this->jobs->find_active_claim( $printer_id );
1250 + if ( null === $claim ) {
1251 + // A result that arrives after the claim timed out belongs to the job
1252 + // that was failed as unconfirmed — record it there instead of
1253 + // dropping it, so a printer that merely reported late shows the truth.
1254 + $claim = $this->jobs->find_unconfirmed( $printer_id );
1255 + } else {
1256 + // A result carries no job token — printjobid is SDP 2.00 only — so it
1257 + // can only be attributed to the active claim. A printer holding an
1258 + // unsent result retries that POST before polling for more work, so a
1259 + // result should not arrive while an earlier job is still awaiting one.
1260 + // Log it if it ever does: the attribution below would be the wrong job.
1261 + $unconfirmed = $this->jobs->find_unconfirmed( $printer_id );
1262 + if ( null !== $unconfirmed ) {
1263 + Logger::warning(
1264 + sprintf(
1265 + 'Printer "%s": result recorded against claimed job %d while unconfirmed job %d is still awaiting one.',
1266 + $printer_id,
1267 + (int) $claim['id'],
1268 + (int) $unconfirmed['id']
1269 + )
1270 + );
1271 + }
1272 + }
1273 + if ( null !== $claim ) {
1274 + $ok = false !== strpos( $result_xml, 'success="true"' );
1275 + $this->jobs->record_printer_result( (int) $claim['id'], $ok );
1276 +
1277 + if ( ! $ok ) {
1278 + $code = 'unknown';
1279 + if ( 1 === preg_match( '/\bcode="([^"]*)"/', $result_xml, $matches ) ) {
1280 + $code = sanitize_text_field( $matches[1] );
1281 + }
1282 +
1283 + $status = null;
1284 + if ( 1 === preg_match( '/\bstatus="(\d+)"/', $result_xml, $matches ) ) {
1285 + // The ASB status is unsigned 32-bit; on a 32-bit PHP build a value
1286 + // with bit 31 set does not fit and (int) would saturate to
1287 + // 0x7FFFFFFF, decoding every label. Such a value is left undecoded
1288 + // rather than misdecoded.
1289 + $status = (float) $matches[1] <= PHP_INT_MAX ? (int) $matches[1] : null;
1290 + }
1291 +
1292 + $this->log_printer_failure( $request, $printer_id, $code, (int) $claim['id'], $status );
1293 + }
1294 + }
1295 +
1296 + return $this->serve_raw( $ack, $soap );
1297 + }
1298 +
1299 + // A status notification or a result post — typed, or recognisable only by
1300 + // its ResponseFile — is not asking for work. Only the GetRequest lane below
1301 + // (and a legacy untyped poll) may be handed a job.
1302 + if ( 'SetStatus' === $connection_type || 'SetResponse' === $connection_type || '' !== $result_xml ) {
1303 + return $this->serve_raw( $ack, $soap );
1304 + }
1305 +
1306 + if ( null !== $this->jobs->find_active_claim( $printer_id ) ) {
1307 + return $this->serve_raw( $ack, $soap );
1308 + }
1309 +
1310 + $job = $this->jobs->next_pending( $printer_id );
1311 + if ( null === $job ) {
1312 + return $this->serve_raw( $ack, $soap );
1313 + }
1314 +
1315 + if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) {
1316 + return $this->serve_raw( $ack, $soap );
1317 + }
1318 + $epos = $this->jobs->render_payload( $job );
1319 +
1320 + // An empty render means the job produced nothing printable — most often
1321 + // a template whose engine this provider cannot render (Server Direct
1322 + // Print only speaks the thermal pipeline's ePOS-Print XML). Dispatching
1323 + // it anyway sends <PrintData></PrintData>: the printer parses that
1324 + // happily, prints nothing, and posts back success="true", so the job is
1325 + // recorded as Printed. Fail the job here instead, so the queue shows the
1326 + // truth and the log names the printer.
1327 + if ( '' === $epos ) {
1328 + Logger::error(
1329 + sprintf(
1330 + '%s: print job %d rendered an empty payload for printer "%s"; nothing was sent to the printer.',
1331 + $request->get_route(),
1332 + (int) $job['id'],
1333 + $printer_id
1334 + )
1335 + );
1336 + update_post_meta( (int) $job['id'], Print_Job_Service::META_ERROR, 'empty_rendered_payload' );
1337 + $this->jobs->set_status( (int) $job['id'], Print_Job_Service::STATUS_FAILED );
1338 +
1339 + return $this->serve_raw( $ack, $soap );
1340 + }
1341 +
1342 + // Server Direct Print expects the print data wrapped in
1343 + // PrintRequestInfo > ePOSPrint > PrintData — NOT the SOAP envelope used
1344 + // by the direct ePOS-Print web service, which is a different protocol.
1345 + // A printer that receives an unrecognised wrapper discards it silently:
1346 + // it neither prints nor posts a result, so the job sits claimed forever.
1347 + // Version 1.00 is the only version every SDP printer family supports
1348 + // (Server Direct Print User's Manual Rev.K, "Response (Print request)");
1349 + // 2.00+ adds printjobid but is limited to TM-i/TM-DT/TM-T88VI.
1350 + $envelope = '<?xml version="1.0" encoding="utf-8"?>';
1351 + $envelope .= '<PrintRequestInfo Version="1.00"><ePOSPrint>';
1352 + $envelope .= '<Parameter><devid>local_printer</devid><timeout>' . self::EPSON_SDP_PRINT_TIMEOUT_MS . '</timeout></Parameter>';
1353 + $envelope .= '<PrintData>' . $epos . '</PrintData>';
1354 + $envelope .= '</ePOSPrint></PrintRequestInfo>';
1355 +
1356 + return $this->serve_raw( $envelope, $soap );
938 1357 }
939 1358
940 1359 /**
941 1360 * Enqueue a print job (raw payload or order-based).
@@ -1096,14 +1515,26 @@
1096 1515 // Legacy printer rows saved before the provider field existed must test
1097 1516 // as the default provider, not fall through to the no-diagnostic error.
1098 1517 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
1099 1518
1100 - $adapter = Provider::adapter( $provider );
1101 - if ( $adapter instanceof Push_Provider_Adapter_Interface ) {
1102 - return $adapter->test_print( $printer );
1519 + if ( 'printnode' === $provider ) {
1520 + return $this->test_print_printnode( $printer );
1103 1521 }
1104 - $diag = $adapter->diagnostic( (string) $printer['name'] );
1105 1522
1523 + if ( 'star-online' === $provider ) {
1524 + return $this->test_print_star_online( $printer_id, $printer );
1525 + }
1526 +
1527 + try {
1528 + $diag = ( new Cloud_Print_Diagnostic() )->build( $provider, (string) $printer['name'] );
1529 + } catch ( \RuntimeException $e ) {
1530 + return new WP_Error(
1531 + 'wcpos_print_job_no_diagnostic',
1532 + __( 'Test print is not available for this printer yet.', 'woocommerce-pos' ),
1533 + array( 'status' => 400 )
1534 + );
1535 + }
1536 +
1106 1537 $id = $this->jobs->create(
1107 1538 array(
1108 1539 'printer_id' => $printer_id,
1109 1540 'content_type' => $diag['content_type'],
@@ -1123,9 +1554,100 @@
1123 1554
1124 1555 return $response;
1125 1556 }
1126 1557
1558 + /**
1559 + * Queue a Star Markup test receipt and submit it through the push pipeline.
1560 + *
1561 + * @param string $printer_id Registered printer id.
1562 + * @param array $printer Registered star-online printer.
1563 + *
1564 + * @return \WP_REST_Response|WP_Error
1565 + */
1566 + private function test_print_star_online( string $printer_id, array $printer ) {
1567 + $markup = ( new Cloud_Print_Diagnostic() )->star_markup( (string) $printer['name'] );
1127 1568
1569 + $id = $this->jobs->create(
1570 + array(
1571 + 'printer_id' => $printer_id,
1572 + 'content_type' => 'text/vnd.star.markup',
1573 + 'payload' => base64_encode( $markup ),
1574 + )
1575 + );
1576 + if ( $id <= 0 ) {
1577 + return new WP_Error(
1578 + 'wcpos_print_job_create_failed',
1579 + __( 'Print job could not be created.', 'woocommerce-pos' ),
1580 + array( 'status' => 500 )
1581 + );
1582 + }
1583 +
1584 + wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $id ) );
1585 + ( new \WCPOS\WooCommercePOS\Services\Cloud_Print_Submit_Service() )->submit( $id );
1586 +
1587 + $response = rest_ensure_response( $this->jobs->get( $id ) );
1588 + $response->set_status( 201 );
1589 +
1590 + return $response;
1591 + }
1592 +
1593 + /**
1594 + * Submit a diagnostic PDF to a PrintNode printer.
1595 + *
1596 + * @param array $printer Registered PrintNode printer.
1597 + *
1598 + * @return \WP_REST_Response|WP_Error
1599 + */
1600 + private function test_print_printnode( array $printer ) {
1601 + $api_key = (string) ( $printer['printnode_api_key'] ?? '' );
1602 + $pn_printer_id = (int) ( $printer['printnode_printer_id'] ?? 0 );
1603 + if ( '' === $api_key || 0 === $pn_printer_id ) {
1604 + return new WP_Error(
1605 + 'wcpos_print_job_printnode_unconfigured',
1606 + __( 'This PrintNode printer is missing its API key or printer id.', 'woocommerce-pos' ),
1607 + array( 'status' => 400 )
1608 + );
1609 + }
1610 +
1611 + try {
1612 + $pdf = ( new Cloud_Print_Diagnostic() )->build_pdf( (string) $printer['name'] );
1613 + } catch ( \Throwable $e ) {
1614 + // Defense in depth: a Dompdf/font-cache/temp-dir failure must not
1615 + // surface as an uncaught 500. Mirror the render_payload() guard.
1616 + Logger::log( 'Cloud print: PrintNode diagnostic PDF render failed: ' . $e->getMessage() );
1617 +
1618 + return new WP_Error(
1619 + 'wcpos_print_job_diagnostic_failed',
1620 + __( 'Could not generate the test print.', 'woocommerce-pos' ),
1621 + array( 'status' => 500 )
1622 + );
1623 + }
1624 +
1625 + $result = ( new PrintNode_Client( $api_key ) )->submit_job(
1626 + $pn_printer_id,
1627 + 'WCPOS Test Print',
1628 + 'pdf_base64',
1629 + base64_encode( $pdf )
1630 + );
1631 +
1632 + if ( is_wp_error( $result ) ) {
1633 + return new WP_Error(
1634 + 'wcpos_print_job_printnode_failed',
1635 + $result->get_error_message(),
1636 + array( 'status' => 502 )
1637 + );
1638 + }
1639 +
1640 + return new WP_REST_Response(
1641 + array(
1642 + 'submitted' => true,
1643 + 'external_provider' => 'printnode',
1644 + 'external_job_id' => (string) $result['id'],
1645 + 'external_state' => 'submitted',
1646 + ),
1647 + 201
1648 + );
1649 + }
1128 1650
1129 1651 /**
1130 1652 * Extract sanitized cash-drawer options from a REST request.
1131 1653 *